diff --git a/2nd-gen/packages/swc/components/asset/test/asset.test.ts b/2nd-gen/packages/swc/components/asset/test/asset.test.ts index 2aa734bfc45..cc24584f52c 100644 --- a/2nd-gen/packages/swc/components/asset/test/asset.test.ts +++ b/2nd-gen/packages/swc/components/asset/test/asset.test.ts @@ -47,9 +47,15 @@ export const OverviewTest: Story = { await step('renders slotted content when no variant is set', async () => { const img = asset.querySelector('img'); - expect(asset.variant).toBeUndefined(); - expect(img).toBeTruthy(); - expect(img?.getAttribute('alt')?.length).toBeGreaterThan(0); + expect( + asset.variant, + 'variant when not set' + ).toBeUndefined(); + expect(img, 'slotted img element is rendered').toBeTruthy(); + expect( + img?.getAttribute('alt')?.length, + 'slotted img has a non-empty alt attribute' + ).toBeGreaterThan(0); }); }, }; @@ -69,21 +75,82 @@ export const DefaultLabelFallbackTest: Story = { await step('file variant falls back to default aria-label', async () => { const fileAsset = assets.find( (a) => a.getAttribute('variant') === 'file' - ) as Asset; - const svg = fileAsset.shadowRoot?.querySelector('svg'); - expect(svg?.getAttribute('aria-label')).toBe('File'); + ) as Asset | null; + expect(fileAsset, 'file asset is rendered').toBeTruthy(); + const svg = fileAsset?.shadowRoot?.querySelector('svg'); + expect(svg, 'file asset has an SVG in shadow DOM').toBeTruthy(); + expect( + svg?.getAttribute('aria-label'), + 'file asset SVG aria-label' + ).toBe('File'); }); await step('folder variant falls back to default aria-label', async () => { const folderAsset = assets.find( (a) => a.getAttribute('variant') === 'folder' - ) as Asset; - const svg = folderAsset.shadowRoot?.querySelector('svg'); - expect(svg?.getAttribute('aria-label')).toBe('Folder'); + ) as Asset | null; + expect(folderAsset, 'folder asset is rendered').toBeTruthy(); + const svg = folderAsset?.shadowRoot?.querySelector('svg'); + expect(svg, 'folder asset has an SVG in shadow DOM').toBeTruthy(); + expect( + svg?.getAttribute('aria-label'), + 'folder asset SVG defaults to "Folder" aria-label' + ).toBe('Folder'); }); }, }; +// ────────────────────────────────────────────────────────────── +// TEST: Properties / Attributes +// ────────────────────────────────────────────────────────────── + +export const LabelMutationTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const asset = await getComponent(canvasElement, 'swc-asset'); + + await step( + 'SVG aria-label reflects default "File" when label is empty', + async () => { + const svg = asset.shadowRoot?.querySelector('svg'); + expect(svg, 'SVG is rendered initially').toBeTruthy(); + expect( + svg?.getAttribute('aria-label'), + 'SVG aria-label defaults to "File" when no label is set' + ).toBe('File'); + } + ); + + await step( + 'SVG aria-label updates when label property is set', + async () => { + asset.label = 'Q4 Budget Report'; + await asset.updateComplete; + const svg = asset.shadowRoot?.querySelector('svg'); + expect( + svg?.getAttribute('aria-label'), + 'SVG aria-label after label update' + ).toBe('Q4 Budget Report'); + } + ); + + await step( + 'SVG aria-label reverts to default when label is cleared', + async () => { + asset.label = ''; + await asset.updateComplete; + const svg = asset.shadowRoot?.querySelector('svg'); + expect( + svg?.getAttribute('aria-label'), + 'SVG aria-label reverts to "File" when label is cleared' + ).toBe('File'); + } + ); + }, +}; + // ────────────────────────────────────────────────────────────── // TEST: Variants / States // ────────────────────────────────────────────────────────────── @@ -101,8 +168,74 @@ export const VariantsTest: Story = { (item) => item.getAttribute('variant') === 'folder' ); - expect(fileAsset).toBeTruthy(); - expect(folderAsset).toBeTruthy(); + expect(fileAsset, 'file variant asset is rendered').toBeTruthy(); + expect(folderAsset, 'folder variant asset is rendered').toBeTruthy(); + }); + }, +}; + +export const VariantMutationTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const asset = await getComponent(canvasElement, 'swc-asset'); + + await step('initially renders slot when no variant is set', async () => { + expect(asset.variant, 'variant is initially undefined').toBeUndefined(); + const svg = asset.shadowRoot?.querySelector('svg'); + expect(svg, 'no SVG is rendered when variant is not set').toBeNull(); + }); + + await step('renders file SVG after variant is set to "file"', async () => { + asset.variant = 'file'; + await asset.updateComplete; + expect( + asset.getAttribute('variant'), + 'variant attribute is "file" after mutation' + ).toBe('file'); + const svg = asset.shadowRoot?.querySelector('svg'); + expect(svg, 'file SVG is rendered after variant mutation').toBeTruthy(); + expect( + svg?.getAttribute('aria-label'), + 'file SVG has default "File" aria-label' + ).toBe('File'); + }); + + await step( + 'renders folder SVG after variant is set to "folder"', + async () => { + asset.variant = 'folder'; + await asset.updateComplete; + expect( + asset.getAttribute('variant'), + 'variant attribute is "folder" after mutation' + ).toBe('folder'); + const svg = asset.shadowRoot?.querySelector('svg'); + expect( + svg, + 'folder SVG is rendered after variant mutation' + ).toBeTruthy(); + expect( + svg?.getAttribute('aria-label'), + 'folder SVG has default "Folder" aria-label' + ).toBe('Folder'); + } + ); + + await step('reverts to slot content when variant is cleared', async () => { + asset.variant = undefined; + await asset.updateComplete; + expect( + asset.variant, + 'variant is undefined after clearing' + ).toBeUndefined(); + expect( + asset.hasAttribute('variant'), + 'variant attribute is absent after clearing' + ).toBe(false); + const svg = asset.shadowRoot?.querySelector('svg'); + expect(svg, 'no SVG is rendered after variant is cleared').toBeNull(); }); }, }; @@ -123,8 +256,14 @@ export const InvalidVariantWarningTest: Story = { asset.variant = 'not-a-variant' as Asset['variant']; await asset.updateComplete; - expect(warnCalls.length).toBeGreaterThan(0); - expect(String(warnCalls[0]?.[1] || '')).toContain('variant'); + expect( + warnCalls.length, + 'at least one warning is emitted for invalid variant' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message references variant' + ).toContain('variant'); }) ); }, @@ -142,7 +281,10 @@ export const ValidVariantNoWarningTest: Story = { asset.variant = 'folder'; await asset.updateComplete; - expect(warnCalls.length).toBe(0); + expect( + warnCalls.length, + 'no warnings are emitted for valid variant' + ).toBe(0); }) ); }, diff --git a/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts b/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts index 5050aea58bb..ae74aa92a1f 100644 --- a/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts +++ b/2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts @@ -50,7 +50,7 @@ argTypes.decorative = { /** * An avatar displays a circular profile image representing a person or entity. */ -export const meta: Meta = { +const meta: Meta = { title: 'Avatar', component: 'swc-avatar', args, @@ -67,11 +67,7 @@ export const meta: Meta = { tags: ['migrated'], }; -export default { - ...meta, - title: 'Avatar', - excludeStories: ['meta'], -} as Meta; +export default meta; // ──────────────────── // HELPERS diff --git a/2nd-gen/packages/swc/components/avatar/test/avatar.test.ts b/2nd-gen/packages/swc/components/avatar/test/avatar.test.ts index f8e7647b472..364e3aa3bfe 100644 --- a/2nd-gen/packages/swc/components/avatar/test/avatar.test.ts +++ b/2nd-gen/packages/swc/components/avatar/test/avatar.test.ts @@ -23,10 +23,9 @@ import { getComponents, withWarningSpy, } from '../../../utils/test-utils.js'; -import { +import meta, { Decorative, Disabled, - meta, Overview, Sizes, } from '../stories/avatar.stories.js'; diff --git a/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts b/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts index 7759f86684d..deae43f63fd 100644 --- a/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts +++ b/2nd-gen/packages/swc/components/badge/stories/badge.stories.ts @@ -90,7 +90,7 @@ argTypes['icon-slot'] = { * Choose one style consistently within a product - `outline` and `subtle` fill draw similar attention levels. * Reserve bold fill for high-attention badging only. */ -export const meta: Meta = { +const meta: Meta = { title: 'Badge', component: 'swc-badge', args, @@ -112,11 +112,7 @@ export const meta: Meta = { tags: ['migrated'], }; -export default { - ...meta, - title: 'Badge', - excludeStories: ['meta'], -} as Meta; +export default meta; // ──────────────────── // HELPERS diff --git a/2nd-gen/packages/swc/components/badge/test/badge.test.ts b/2nd-gen/packages/swc/components/badge/test/badge.test.ts index d8e3ccd1da9..a878b481783 100644 --- a/2nd-gen/packages/swc/components/badge/test/badge.test.ts +++ b/2nd-gen/packages/swc/components/badge/test/badge.test.ts @@ -29,8 +29,7 @@ import { getComponents, withWarningSpy, } from '../../../utils/test-utils.js'; -import { meta } from '../stories/badge.stories.js'; -import { +import meta, { Anatomy, Fixed, NonSemanticVariants, @@ -61,9 +60,12 @@ export const OverviewTest: Story = { const badge = await getComponent(canvasElement, 'swc-badge'); await step('renders expected default values and slot content', async () => { - expect(badge.variant).toBe('neutral'); - expect(badge.size).toBe('m'); - expect(badge.textContent?.trim()).toBeTruthy(); + expect(badge.variant, 'default variant is neutral').toBe('neutral'); + expect(badge.size, 'default size is m').toBe('m'); + expect( + badge.textContent?.trim(), + 'default slot has text content' + ).toBeTruthy(); }); }, }; @@ -80,23 +82,35 @@ export const PropertyMutationTest: Story = { await step('variant reflects to attribute after mutation', async () => { badge.variant = 'positive'; await badge.updateComplete; - expect(badge.getAttribute('variant')).toBe('positive'); + expect( + badge.getAttribute('variant'), + 'variant attribute is positive after mutation' + ).toBe('positive'); badge.variant = 'notice'; await badge.updateComplete; - expect(badge.getAttribute('variant')).toBe('notice'); + expect( + badge.getAttribute('variant'), + 'variant attribute is notice after second mutation' + ).toBe('notice'); }); await step('subtle reflects to attribute after mutation', async () => { badge.subtle = true; await badge.updateComplete; - expect(badge.hasAttribute('subtle')).toBe(true); + expect( + badge.hasAttribute('subtle'), + 'subtle attribute is present after setting subtle=true' + ).toBe(true); }); await step('outline reflects to attribute after mutation', async () => { badge.outline = true; await badge.updateComplete; - expect(badge.hasAttribute('outline')).toBe(true); + expect( + badge.hasAttribute('outline'), + 'outline attribute is present after setting outline=true' + ).toBe(true); }); }, }; @@ -109,16 +123,24 @@ export const FixedClearingTest: Story = { const badge = await getComponent(canvasElement, 'swc-badge'); await step('initially has fixed attribute', async () => { - expect(badge.fixed).toBe('block-start'); - expect(badge.hasAttribute('fixed')).toBe(true); + expect(badge.fixed, 'fixed property is block-start initially').toBe( + 'block-start' + ); + expect( + badge.hasAttribute('fixed'), + 'fixed attribute is present initially' + ).toBe(true); }); await step('removes fixed attribute when set to undefined', async () => { badge.fixed = undefined; await badge.updateComplete; - expect(badge.fixed).toBeFalsy(); - expect(badge.hasAttribute('fixed')).toBe(false); + expect(badge.fixed, 'fixed property is falsy after clearing').toBeFalsy(); + expect( + badge.hasAttribute('fixed'), + 'fixed attribute is absent after clearing' + ).toBe(false); }); }, }; @@ -136,10 +158,16 @@ export const AnatomyTest: Story = { const badgeWithIcon = badges.find((item) => item.querySelector('[slot="icon"]') ); - expect(badgeWithIcon).toBeTruthy(); + expect( + badgeWithIcon, + 'at least one badge has icon slot content' + ).toBeTruthy(); const slottedIcon = badgeWithIcon?.querySelector('[slot="icon"]'); - expect(slottedIcon).toBeTruthy(); - expect(slottedIcon?.children.length).toBeGreaterThan(0); + expect(slottedIcon, 'icon slot element is present').toBeTruthy(); + expect( + slottedIcon?.children.length, + 'icon slot has child elements' + ).toBeGreaterThan(0); }); }, }; @@ -155,9 +183,15 @@ export const SemanticVariantsTest: Story = { for (const variant of BADGE_VARIANTS_SEMANTIC) { const badge = canvasElement.querySelector( `swc-badge[variant="${variant}"]` - ) as Badge; - await badge.updateComplete; - expect(badge.variant).toBe(variant); + ) as Badge | null; + expect( + badge, + `badge with variant="${variant}" is rendered` + ).toBeTruthy(); + await badge?.updateComplete; + expect(badge?.variant, `badge variant property is "${variant}"`).toBe( + variant + ); } }); }, @@ -172,9 +206,16 @@ export const OutlineTest: Story = { for (const variant of BADGE_VARIANTS_SEMANTIC) { const badge = canvasElement.querySelector( `swc-badge[variant="${variant}"]` - ) as Badge; - await badge.updateComplete; - expect(badge.hasAttribute('outline')).toBe(true); + ) as Badge | null; + expect( + badge, + `badge with variant="${variant}" is rendered` + ).toBeTruthy(); + await badge?.updateComplete; + expect( + badge?.hasAttribute('outline'), + `badge with variant="${variant}" has outline attribute` + ).toBe(true); } } ); @@ -194,9 +235,10 @@ export const SizesTest: Story = { for (const size of BADGE_VALID_SIZES) { const badge = canvasElement.querySelector( `swc-badge[size="${size}"]` - ) as Badge; - await badge.updateComplete; - expect(badge.size).toBe(size); + ) as Badge | null; + expect(badge, `badge with size="${size}" is rendered`).toBeTruthy(); + await badge?.updateComplete; + expect(badge?.size, `badge size property is "${size}"`).toBe(size); } }); }, @@ -209,9 +251,16 @@ export const SubtleTest: Story = { for (const variant of BADGE_VARIANTS) { const badge = canvasElement.querySelector( `swc-badge[variant="${variant}"]` - ) as Badge; - await badge.updateComplete; - expect(badge.hasAttribute('subtle')).toBe(true); + ) as Badge | null; + expect( + badge, + `badge with variant="${variant}" is rendered` + ).toBeTruthy(); + await badge?.updateComplete; + expect( + badge?.hasAttribute('subtle'), + `badge with variant="${variant}" has subtle attribute` + ).toBe(true); } }); }, @@ -224,9 +273,10 @@ export const FixedTest: Story = { for (const value of FIXED_VALUES) { const badge = canvasElement.querySelector( `swc-badge[fixed="${value}"]` - ) as Badge; - await badge.updateComplete; - expect(badge.fixed).toBe(value); + ) as Badge | null; + expect(badge, `badge with fixed="${value}" is rendered`).toBeTruthy(); + await badge?.updateComplete; + expect(badge?.fixed, `badge fixed property is "${value}"`).toBe(value); } }); }, @@ -236,15 +286,19 @@ export const NonSemanticVariantsTest: Story = { ...NonSemanticVariants, play: async ({ canvasElement, step }) => { await step('renders all color variant values', async () => { - await Promise.all( - BADGE_VARIANTS_COLOR.map(async (variant) => { - const badge = canvasElement.querySelector( - `swc-badge[variant="${variant}"]` - ) as Badge; - await badge.updateComplete; - expect(badge.variant).toBe(variant); - }) - ); + for (const variant of BADGE_VARIANTS_COLOR) { + const badge = canvasElement.querySelector( + `swc-badge[variant="${variant}"]` + ) as Badge | null; + expect( + badge, + `badge with variant="${variant}" is rendered` + ).toBeTruthy(); + await badge?.updateComplete; + expect(badge?.variant, `badge variant property is "${variant}"`).toBe( + variant + ); + } }); }, }; @@ -265,8 +319,14 @@ export const InvalidVariantWarningTest: Story = { badge.variant = 'not-a-variant' as unknown as Badge['variant']; await badge.updateComplete; - expect(warnCalls.length).toBeGreaterThan(0); - expect(String(warnCalls[0]?.[1] || '')).toContain('variant'); + expect( + warnCalls.length, + 'at least one warning is emitted for invalid variant' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message references variant' + ).toContain('variant'); }) ); }, @@ -284,7 +344,10 @@ export const ValidVariantNoWarningTest: Story = { badge.variant = 'negative'; await badge.updateComplete; - expect(warnCalls.length).toBe(0); + expect( + warnCalls.length, + 'no warnings are emitted for valid variant' + ).toBe(0); }) ); }, @@ -302,8 +365,14 @@ export const OutlineNonSemanticWarningTest: Story = { badge.requestUpdate(); await badge.updateComplete; - expect(warnCalls.length).toBeGreaterThan(0); - expect(String(warnCalls[0]?.[1] || '')).toContain('outline'); + expect( + warnCalls.length, + 'at least one warning is emitted for outline with non-semantic variant' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message references outline' + ).toContain('outline'); }) ); }, diff --git a/2nd-gen/packages/swc/components/divider/test/divider.test.ts b/2nd-gen/packages/swc/components/divider/test/divider.test.ts index 8f823f0805e..58ddd0fdafc 100644 --- a/2nd-gen/packages/swc/components/divider/test/divider.test.ts +++ b/2nd-gen/packages/swc/components/divider/test/divider.test.ts @@ -17,10 +17,18 @@ import { Divider } from '@adobe/spectrum-wc/divider'; import '@adobe/spectrum-wc/divider'; -import { getComponent, getComponents } from '../../../utils/test-utils.js'; -import meta from '../stories/divider.stories.js'; import { + DIVIDER_STATIC_COLORS, + DIVIDER_VALID_SIZES, +} from '../../../../core/components/divider/Divider.types.js'; +import { + getComponent, + getComponents, + withWarningSpy, +} from '../../../utils/test-utils.js'; +import meta, { Overview, + Sizes, StaticColors, Vertical, } from '../stories/divider.stories.js'; @@ -46,8 +54,10 @@ export const OverviewTest: Story = { const divider = await getComponent(canvasElement, 'swc-divider'); await step('renders a separator with expected attributes', async () => { - expect(divider.getAttribute('role')).toBe('separator'); - expect(divider.size).toBe('m'); + expect(divider.getAttribute('role'), 'divider has role="separator"').toBe( + 'separator' + ); + expect(divider.size, 'default size is m').toBe('m'); }); }, }; @@ -56,20 +66,83 @@ export const OverviewTest: Story = { // TEST: Properties / Attributes // ────────────────────────────────────────────────────────────── +export const SizesTest: Story = { + ...Sizes, + play: async ({ canvasElement, step }) => { + await step('renders dividers in all valid sizes', async () => { + for (const size of DIVIDER_VALID_SIZES) { + const divider = canvasElement.querySelector( + `swc-divider[size="${size}"]` + ) as Divider | null; + expect(divider, `divider with size="${size}" is rendered`).toBeTruthy(); + await divider?.updateComplete; + expect(divider?.size, `divider size property is "${size}"`).toBe(size); + } + }); + }, +}; + export const VerticalTest: Story = { ...Vertical, play: async ({ canvasElement, step }) => { const dividers = await getComponents(canvasElement, 'swc-divider'); await step('reflects vertical orientation attributes', async () => { - dividers.forEach((divider) => { - expect(divider.hasAttribute('vertical')).toBe(true); - expect(divider.getAttribute('aria-orientation')).toBe('vertical'); - }); + for (const divider of dividers) { + expect( + divider.hasAttribute('vertical'), + 'divider has vertical attribute' + ).toBe(true); + expect( + divider.getAttribute('aria-orientation'), + 'aria-orientation is set to vertical' + ).toBe('vertical'); + } }); }, }; +export const VerticalMutationTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const divider = await getComponent(canvasElement, 'swc-divider'); + + await step('initially has no aria-orientation', async () => { + expect(divider.vertical, 'vertical is false by default').toBe(false); + expect( + divider.hasAttribute('aria-orientation'), + 'aria-orientation is absent when not vertical' + ).toBe(false); + }); + + await step( + 'sets aria-orientation="vertical" when vertical is enabled', + async () => { + divider.vertical = true; + await divider.updateComplete; + expect( + divider.getAttribute('aria-orientation'), + 'aria-orientation is set to vertical after enabling' + ).toBe('vertical'); + } + ); + + await step( + 'removes aria-orientation when vertical is disabled', + async () => { + divider.vertical = false; + await divider.updateComplete; + expect( + divider.hasAttribute('aria-orientation'), + 'aria-orientation is absent after disabling vertical' + ).toBe(false); + } + ); + }, +}; + export const StaticColorsTest: Story = { ...StaticColors, play: async ({ canvasElement, step }) => { @@ -79,11 +152,14 @@ export const StaticColorsTest: Story = { ); await step('reflects expected static-color attribute values', async () => { - dividers.forEach((divider) => { + for (const divider of dividers) { const staticColor = divider.getAttribute('static-color'); - expect(staticColor).toBeTruthy(); - expect(['white', 'black']).toContain(staticColor); - }); + expect(staticColor, 'static-color attribute is present').toBeTruthy(); + expect( + ['white', 'black'], + `static-color "${staticColor}" is a valid value` + ).toContain(staticColor); + } }); }, }; @@ -96,15 +172,79 @@ export const StaticColorToggleTest: Story = { const divider = await getComponent(canvasElement, 'swc-divider'); await step('renders with static-color attribute', async () => { - expect(divider.getAttribute('static-color')).toBe('black'); + expect( + divider.getAttribute('static-color'), + 'initial static-color attribute' + ).toBe('black'); }); await step('clears static-color when attribute is removed', async () => { divider.removeAttribute('static-color'); divider.requestUpdate(); await divider.updateComplete; - expect(divider.getAttribute('static-color')).toBeNull(); - expect(divider.hasAttribute('static-color')).toBe(false); + expect( + divider.getAttribute('static-color'), + 'static-color attribute is null after removal' + ).toBeNull(); + expect( + divider.hasAttribute('static-color'), + 'static-color attribute is absent after removal' + ).toBe(false); }); }, }; + +// ────────────────────────────────────────────────────────────── +// TEST: Dev mode warnings +// ────────────────────────────────────────────────────────────── + +export const InvalidStaticColorWarningTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const divider = await getComponent(canvasElement, 'swc-divider'); + + await step('warns when an invalid static-color is set in DEBUG mode', () => + withWarningSpy(async (warnCalls) => { + divider.staticColor = + 'not-a-color' as unknown as Divider['staticColor']; + await divider.updateComplete; + + expect( + warnCalls.length, + 'at least one warning is emitted for invalid static-color' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning message references static-color attribute' + ).toContain('static-color'); + }) + ); + }, +}; + +export const ValidStaticColorNoWarningTest: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const divider = await getComponent(canvasElement, 'swc-divider'); + + await step( + 'does not warn for any valid static-color value in DEBUG mode', + () => + withWarningSpy(async (warnCalls) => { + for (const color of DIVIDER_STATIC_COLORS) { + divider.staticColor = color; + await divider.updateComplete; + } + + expect( + warnCalls.length, + 'no warnings are emitted for valid static-color values' + ).toBe(0); + }) + ); + }, +}; diff --git a/2nd-gen/packages/swc/components/icon/test/icon.a11y.spec.ts b/2nd-gen/packages/swc/components/icon/test/icon.a11y.spec.ts new file mode 100644 index 00000000000..0b700a5a9c6 --- /dev/null +++ b/2nd-gen/packages/swc/components/icon/test/icon.a11y.spec.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, test } from '@playwright/test'; + +import { gotoStory } from '../../../utils/a11y-helpers.js'; + +/** + * Accessibility tests for Icon component (2nd Generation) + * + * ARIA snapshot tests validate the accessibility tree structure. + * aXe WCAG compliance and color contrast validation are run via + * test-storybook (see .storybook/test-runner.ts). Both are included + * in the `test:a11y` command. + */ + +test.describe('Icon - ARIA Snapshots', () => { + test('should expose labelled icon as img with aria-label in accessibility tree', async ({ + page, + }) => { + const root = await gotoStory(page, 'components-icon--overview', 'swc-icon'); + await expect(root).toMatchAriaSnapshot(` + - img "Search" + `); + }); + + test('should expose anatomy icon with correct aria-label', async ({ + page, + }) => { + const root = await gotoStory(page, 'components-icon--anatomy', 'swc-icon'); + await expect(root).toMatchAriaSnapshot(` + - img "Chevron icon" + `); + }); + + test('should expose all sizes with correct aria-labels', async ({ page }) => { + const root = await gotoStory(page, 'components-icon--sizes', 'swc-icon'); + await expect(root).toMatchAriaSnapshot(` + - img "Extra-small" + - img "Small" + - img "Medium" + - img "Large" + - img "Extra-large" + `); + }); +}); diff --git a/2nd-gen/packages/swc/components/icon/test/icon.test.ts b/2nd-gen/packages/swc/components/icon/test/icon.test.ts index 15b07e21756..eeb5c905ced 100644 --- a/2nd-gen/packages/swc/components/icon/test/icon.test.ts +++ b/2nd-gen/packages/swc/components/icon/test/icon.test.ts @@ -42,8 +42,8 @@ export const OverviewTest: Story = { const icon = await getComponent(canvasElement, 'swc-icon'); await step('renders with expected default properties', async () => { - expect(icon.label).toBe('Search'); - expect(icon.shadowRoot).toBeTruthy(); + expect(icon.label, 'label property is "Search"').toBe('Search'); + expect(icon.shadowRoot, 'shadow root is attached').toBeTruthy(); }); }, }; @@ -66,7 +66,7 @@ export const SizeAttributeTest: Story = { const icon = await getComponent(canvasElement, 'swc-icon'); await step('reflects size attribute on host', async () => { - expect(icon.getAttribute('size')).toBe('xl'); + expect(icon.getAttribute('size'), 'size attribute is "xl"').toBe('xl'); }); }, }; @@ -90,8 +90,13 @@ export const SlottedSvgAccessibilityTest: Story = { await step('applies aria attributes to slotted svg', async () => { const svg = icon.querySelector('svg')!; - expect(svg.getAttribute('role')).toBe('img'); - expect(svg.getAttribute('aria-label')).toBe('Search'); + expect(svg.getAttribute('role'), 'slotted SVG has role="img"').toBe( + 'img' + ); + expect( + svg.getAttribute('aria-label'), + 'slotted SVG aria-label matches icon label' + ).toBe('Search'); }); }, }; @@ -111,9 +116,18 @@ export const NoLabelAriaHiddenTest: Story = { await step('applies aria-hidden when no label', async () => { const svg = icon.querySelector('svg')!; - expect(svg.getAttribute('aria-hidden')).toBe('true'); - expect(svg.hasAttribute('aria-label')).toBe(false); - expect(icon.getAttribute('aria-hidden')).toBe('true'); + expect( + svg.getAttribute('aria-hidden'), + 'slotted SVG has aria-hidden="true" when no label' + ).toBe('true'); + expect( + svg.hasAttribute('aria-label'), + 'slotted SVG has no aria-label when icon has no label' + ).toBe(false); + expect( + icon.getAttribute('aria-hidden'), + 'host element has aria-hidden="true" when no label' + ).toBe('true'); }); }, }; @@ -133,22 +147,40 @@ export const LabelTogglingTest: Story = { const svg = () => icon.querySelector('svg')!; await step('initial label "x" sets aria-label on svg', async () => { - expect(svg().getAttribute('aria-label')).toBe('x'); - expect(svg().getAttribute('aria-hidden')).toBeNull(); + expect( + svg().getAttribute('aria-label'), + 'SVG aria-label is "x" initially' + ).toBe('x'); + expect( + svg().getAttribute('aria-hidden'), + 'SVG has no aria-hidden when label is set' + ).toBeNull(); }); await step('clearing label sets aria-hidden on svg', async () => { icon.label = ''; await icon.updateComplete; - expect(svg().getAttribute('aria-hidden')).toBe('true'); - expect(svg().hasAttribute('aria-label')).toBe(false); + expect( + svg().getAttribute('aria-hidden'), + 'SVG has aria-hidden="true" after label is cleared' + ).toBe('true'); + expect( + svg().hasAttribute('aria-label'), + 'SVG has no aria-label after label is cleared' + ).toBe(false); }); await step('setting label "y" restores aria-label on svg', async () => { icon.label = 'y'; await icon.updateComplete; - expect(svg().getAttribute('aria-label')).toBe('y'); - expect(svg().getAttribute('aria-hidden')).toBeNull(); + expect( + svg().getAttribute('aria-label'), + 'SVG aria-label is "y" after setting new label' + ).toBe('y'); + expect( + svg().getAttribute('aria-hidden'), + 'SVG has no aria-hidden after label is restored' + ).toBeNull(); }); }, }; diff --git a/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts b/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts index 5a137eb37a4..1e757bf48bd 100644 --- a/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts +++ b/2nd-gen/packages/swc/components/progress-circle/test/progress-circle.test.ts @@ -82,7 +82,7 @@ export const SizesTest: Story = { await step('renders expected size attributes', async () => { circles.forEach((circle) => { const size = circle.getAttribute('size'); - expect(size).toBeTruthy(); + expect(size, `progress circle has a size attribute`).toBeTruthy(); }); }); }, @@ -99,8 +99,14 @@ export const StaticColorsTest: Story = { await step('reflects expected static-color attribute values', async () => { circles.forEach((circle) => { const staticColor = circle.getAttribute('static-color'); - expect(staticColor).toBeTruthy(); - expect(['white', 'black']).toContain(staticColor); + expect( + staticColor, + 'progress circle has a static-color attribute' + ).toBeTruthy(); + expect( + ['white', 'black'], + `static-color "${staticColor}" is a valid value` + ).toContain(staticColor); }); }); }, @@ -120,7 +126,10 @@ export const LabelClearingTest: Story = { ); await step('initially has aria-label set from label', async () => { - expect(progressCircle.getAttribute('aria-label')).toBe('Uploading file'); + expect( + progressCircle.getAttribute('aria-label'), + 'aria-label is set from the label property' + ).toBe('Uploading file'); }); await step( @@ -154,7 +163,10 @@ export const AriaLabelAccessibleNameTest: Story = { progressCircle.progress = 40; await progressCircle.updateComplete; - expect(warnCalls.length).toBe(0); + expect( + warnCalls.length, + 'no warnings are emitted when aria-label provides the accessible name' + ).toBe(0); }) ); }, @@ -180,7 +192,10 @@ export const AriaLabelledbyAccessibleNameTest: Story = { progressCircle.progress = 70; await progressCircle.updateComplete; - expect(warnCalls.length).toBe(0); + expect( + warnCalls.length, + 'no warnings are emitted when aria-labelledby provides the accessible name' + ).toBe(0); }) ); }, @@ -244,10 +259,14 @@ export const LightDomWithLabelDeprecationOnlyTest: Story = { progressCircle.progress = 6; await progressCircle.updateComplete; - expect(warnCalls.length).toBe(1); - expect(String(warnCalls[0]?.[1] ?? '')).toContain( - 'no longer has a default slot' - ); + expect( + warnCalls.length, + 'exactly one warning is emitted for light DOM when label provides the accessible name' + ).toBe(1); + expect( + String(warnCalls[0]?.[1] ?? ''), + 'the warning message references the removed default slot' + ).toContain('no longer has a default slot'); }) ); }, @@ -308,13 +327,25 @@ export const ProgressClampTest: Story = { ); await step('clamps progress above 100 to 100', async () => { - expect(circles[0].progress).toBe(100); - expect(circles[0].getAttribute('aria-valuenow')).toBe('100'); + expect( + circles[0].progress, + 'progress property is clamped to 100 when set to 150' + ).toBe(100); + expect( + circles[0].getAttribute('aria-valuenow'), + 'aria-valuenow reflects clamped value of 100' + ).toBe('100'); }); await step('clamps progress below 0 to 0', async () => { - expect(circles[1].progress).toBe(0); - expect(circles[1].getAttribute('aria-valuenow')).toBe('0'); + expect( + circles[1].progress, + 'progress property is clamped to 0 when set to -20' + ).toBe(0); + expect( + circles[1].getAttribute('aria-valuenow'), + 'aria-valuenow reflects clamped value of 0' + ).toBe('0'); }); }, }; @@ -345,8 +376,14 @@ export const ReturnToIndeterminateTest: Story = { ); await step('renders determinate progress with aria-valuenow', async () => { - expect(progressCircle.hasAttribute('aria-valuenow')).toBe(true); - expect(progressCircle.getAttribute('aria-valuenow')).toBe('50'); + expect( + progressCircle.hasAttribute('aria-valuenow'), + 'aria-valuenow is present in determinate state' + ).toBe(true); + expect( + progressCircle.getAttribute('aria-valuenow'), + 'aria-valuenow reflects the initial progress of 50' + ).toBe('50'); }); await step( @@ -355,7 +392,10 @@ export const ReturnToIndeterminateTest: Story = { progressCircle.progress = null; await progressCircle.updateComplete; - expect(progressCircle.hasAttribute('aria-valuenow')).toBe(false); + expect( + progressCircle.hasAttribute('aria-valuenow'), + 'aria-valuenow is removed after switching to indeterminate' + ).toBe(false); } ); }, @@ -389,8 +429,14 @@ export const AccessibilityWarningTest: Story = { progressCircle.label = ''; await progressCircle.updateComplete; - expect(warnCalls.length).toBeGreaterThan(0); - expect(String(warnCalls[0]?.[1] || '')).toContain('accessible'); + expect( + warnCalls.length, + 'at least one warning is emitted when there is no accessible name' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'the warning message references the accessible name requirement' + ).toContain('accessible'); }) ); }, diff --git a/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts b/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts index e79b2b7d353..469e0647480 100644 --- a/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts +++ b/2nd-gen/packages/swc/components/status-light/stories/status-light.stories.ts @@ -63,7 +63,7 @@ argTypes.size = { /** * Status lights describe the condition of an entity. Much like [badges](../?path=/docs/components-badge--readme), they can be used to convey semantic meaning, such as statuses and categories. */ -export const meta: Meta = { +const meta: Meta = { title: 'Status light', component: 'swc-status-light', parameters: { @@ -85,11 +85,7 @@ export const meta: Meta = { tags: ['migrated'], }; -export default { - ...meta, - title: 'Status light', - excludeStories: ['meta'], -} as Meta; +export default meta; // ──────────────────── // HELPERS @@ -215,12 +211,13 @@ export const Sizes: Story = { */ export const SemanticVariants: Story = { render: (args) => html` - ${STATUS_LIGHT_VARIANTS_SEMANTIC.map((variant: StatusLightSemanticVariant) => - template({ - ...args, - variant, - 'default-slot': semanticLabels[variant], - }) + ${STATUS_LIGHT_VARIANTS_SEMANTIC.map( + (variant: StatusLightSemanticVariant) => + template({ + ...args, + variant, + 'default-slot': semanticLabels[variant], + }) )} `, parameters: { diff --git a/2nd-gen/packages/swc/components/status-light/test/status-light.test.ts b/2nd-gen/packages/swc/components/status-light/test/status-light.test.ts index a54c2270afa..f5c94c2634a 100644 --- a/2nd-gen/packages/swc/components/status-light/test/status-light.test.ts +++ b/2nd-gen/packages/swc/components/status-light/test/status-light.test.ts @@ -21,9 +21,20 @@ import { StatusLight } from '@adobe/spectrum-wc/status-light'; import '@adobe/spectrum-wc/status-light'; +import { + STATUS_LIGHT_VALID_SIZES, + STATUS_LIGHT_VARIANTS_COLOR, + STATUS_LIGHT_VARIANTS_SEMANTIC, +} from '../../../../core/components/status-light/StatusLight.types.js'; import { getComponent, withWarningSpy } from '../../../utils/test-utils.js'; -import { meta } from '../stories/status-light.stories.js'; -import { Overview, Playground } from '../stories/status-light.stories.js'; +import meta, { + Anatomy, + NonSemanticVariants, + Overview, + Playground, + SemanticVariants, + Sizes, +} from '../stories/status-light.stories.js'; // This file defines dev-only test stories that reuse the main story metadata. export default { @@ -49,9 +60,36 @@ export const DefaultTest: Story = { ); await step('renders default properties and slot content', async () => { - expect(statusLight.variant).toBe('info'); - expect(statusLight.size).toBe('m'); - expect(statusLight.textContent?.trim()).toBeTruthy(); + expect(statusLight.variant, 'default variant is info').toBe('info'); + expect(statusLight.size, 'default size is m').toBe('m'); + expect( + statusLight.textContent?.trim(), + 'default slot has text content' + ).toBeTruthy(); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Slots +// ────────────────────────────────────────────────────────────── + +export const AnatomyTest: Story = { + ...Anatomy, + play: async ({ canvasElement, step }) => { + const statusLight = await getComponent( + canvasElement, + 'swc-status-light' + ); + + await step('renders with correct variant and slot content', async () => { + expect(statusLight.variant, 'anatomy story variant is positive').toBe( + 'positive' + ); + expect( + statusLight.textContent?.trim(), + 'default slot text content is present' + ).toBeTruthy(); }); }, }; @@ -61,22 +99,27 @@ export const DefaultTest: Story = { // ────────────────────────────────────────────────────────────── export const SizesTest: Story = { - render: () => html` - ${StatusLight.VALID_SIZES.map( - (size) => html` - ${size} - ` - )} - `, + ...Sizes, play: async ({ canvasElement, step }) => { await step('renders and reflects each size correctly', async () => { - StatusLight.VALID_SIZES.forEach((size) => { + for (const size of STATUS_LIGHT_VALID_SIZES) { const statusLight = canvasElement.querySelector( `swc-status-light[size="${size}"]` - ) as StatusLight; - expect(statusLight.variant).toBe('info'); - expect(statusLight.size).toBe(size); - }); + ) as StatusLight | null; + expect( + statusLight, + `status light with size="${size}" is rendered` + ).toBeTruthy(); + await statusLight?.updateComplete; + expect( + statusLight?.variant, + `status light with size="${size}" has variant info` + ).toBe('info'); + expect( + statusLight?.size, + `status light size property is "${size}"` + ).toBe(size); + } }); }, }; @@ -105,13 +148,115 @@ export const ComposedComponentTest: Story = { ); await step('renders within composed content', async () => { - expect(statusLight.variant).toBe('positive'); - expect(statusLight.size).toBe('m'); - expect(statusLight.textContent?.trim()).toBeTruthy(); + expect( + statusLight.variant, + 'variant is positive in composed context' + ).toBe('positive'); + expect(statusLight.size, 'size is m in composed context').toBe('m'); + expect( + statusLight.textContent?.trim(), + 'slot content is present in composed context' + ).toBeTruthy(); }); }, }; +// ────────────────────────────────────────────────────────────── +// TEST: Variants / States +// ────────────────────────────────────────────────────────────── + +export const SemanticVariantsTest: Story = { + ...SemanticVariants, + play: async ({ canvasElement, step }) => { + await step('renders all semantic variants', async () => { + for (const variant of STATUS_LIGHT_VARIANTS_SEMANTIC) { + const statusLight = canvasElement.querySelector( + `swc-status-light[variant="${variant}"]` + ) as StatusLight | null; + expect( + statusLight, + `status light with variant="${variant}" is rendered` + ).toBeTruthy(); + await statusLight?.updateComplete; + expect( + statusLight?.variant, + `status light variant property is "${variant}"` + ).toBe(variant); + } + }); + }, +}; + +export const NonSemanticVariantsTest: Story = { + ...NonSemanticVariants, + play: async ({ canvasElement, step }) => { + await step('renders all non-semantic color variants', async () => { + for (const variant of STATUS_LIGHT_VARIANTS_COLOR) { + const statusLight = canvasElement.querySelector( + `swc-status-light[variant="${variant}"]` + ) as StatusLight | null; + expect( + statusLight, + `status light with variant="${variant}" is rendered` + ).toBeTruthy(); + await statusLight?.updateComplete; + expect( + statusLight?.variant, + `status light variant property is "${variant}"` + ).toBe(variant); + } + }); + }, +}; + +export const VariantMutationTest: Story = { + render: () => html` + Active + `, + play: async ({ canvasElement, step }) => { + const statusLight = await getComponent( + canvasElement, + 'swc-status-light' + ); + + await step( + 'reflects variant attribute after mutation to positive', + async () => { + statusLight.variant = 'positive'; + await statusLight.updateComplete; + expect( + statusLight.getAttribute('variant'), + 'variant attribute is positive after mutation' + ).toBe('positive'); + } + ); + + await step( + 'reflects variant attribute after mutation to negative', + async () => { + statusLight.variant = 'negative'; + await statusLight.updateComplete; + expect( + statusLight.getAttribute('variant'), + 'variant attribute is negative after second mutation' + ).toBe('negative'); + } + ); + + await step( + 'reflects variant attribute after mutation to non-semantic color variant', + async () => { + statusLight.variant = 'seafoam'; + await statusLight.updateComplete; + expect( + statusLight.getAttribute('variant'), + 'variant attribute is seafoam after mutation to color variant' + ).toBe('seafoam'); + } + ); + }, +}; + // ────────────────────────────────────────────────────────────── // TEST: Dev mode warnings // ────────────────────────────────────────────────────────────── @@ -130,12 +275,63 @@ export const UnsupportedVariantWarningTest: Story = { statusLight.setAttribute('variant', 'accent'); await statusLight.updateComplete; - expect(warnCalls.length).toBeGreaterThan(0); - expect(warnCalls[0][0]).toBe(statusLight); - expect(warnCalls[0][1]).toBe( + expect( + warnCalls.length, + 'at least one warning is emitted for unsupported variant' + ).toBeGreaterThan(0); + expect( + warnCalls[0][0], + 'warning is emitted from the status light element' + ).toBe(statusLight); + expect( + warnCalls[0][1], + 'warning message references the variant attribute' + ).toBe( `<${statusLight.localName}> element expects the "variant" attribute to be one of the following:` ); }) ); }, }; + +export const ValidVariantNoWarningTest: Story = { + render: () => html` + Active + `, + play: async ({ canvasElement, step }) => { + const statusLight = await getComponent( + canvasElement, + 'swc-status-light' + ); + + await step( + 'does not warn when a valid semantic variant is set in DEBUG mode', + () => + withWarningSpy(async (warnCalls) => { + for (const variant of STATUS_LIGHT_VARIANTS_SEMANTIC) { + statusLight.variant = variant; + await statusLight.updateComplete; + } + + expect( + warnCalls.length, + 'no warnings are emitted for valid semantic variants' + ).toBe(0); + }) + ); + + await step( + 'does not warn when a valid color variant is set in DEBUG mode', + () => + withWarningSpy(async (warnCalls) => { + statusLight.variant = 'seafoam'; + await statusLight.updateComplete; + + expect( + warnCalls.length, + 'no warnings are emitted for valid color variants' + ).toBe(0); + }) + ); + }, +}; diff --git a/2nd-gen/packages/swc/components/typography/test/typography.a11y.spec.ts b/2nd-gen/packages/swc/components/typography/test/typography.a11y.spec.ts new file mode 100644 index 00000000000..4fa791bf227 --- /dev/null +++ b/2nd-gen/packages/swc/components/typography/test/typography.a11y.spec.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +/** + * Accessibility tests for Typography styles (2nd Generation) + * + * Typography is a CSS-only utility — there is no `` custom element. + * This local helper navigates to a story and waits for the `.typography-samples` + * container to appear rather than using `customElements.whenDefined()`, which only + * works for custom elements with a hyphen in the tag name. + * + * ARIA snapshot tests validate the accessibility tree structure. + * aXe WCAG compliance and color contrast validation are run via + * test-storybook (see .storybook/test-runner.ts). Both are included + * in the `test:a11y` command. + */ +async function gotoTypographyStory( + page: Page, + storyId: string +): Promise { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`, { + waitUntil: 'domcontentloaded', + }); + + // Wait for the Storybook root to contain rendered content + await page.waitForFunction(() => { + const root = document.querySelector('#storybook-root'); + return root && root.children.length > 0; + }); + + // Wait for the typography wrapper to be visible (CSS-only component) + await page + .locator('.typography-samples') + .first() + .waitFor({ state: 'visible' }); + + return page.locator('#storybook-root'); +} + +test.describe('Typography - ARIA Snapshots', () => { + test('heading variant renders an accessible level-2 heading', async ({ + page, + }) => { + const root = await gotoTypographyStory( + page, + 'components-typography--playground' + ); + await expect(root).toMatchAriaSnapshot(` + - heading /Reserved for main page heading/ [level=2] + `); + }); + + test('heading variant with all sizes renders multiple accessible headings', async ({ + page, + }) => { + const root = await gotoTypographyStory( + page, + 'components-typography--heading-variant' + ); + await expect(root).toMatchAriaSnapshot(` + - heading /Reserved for main page heading/ [level=2] + - heading /Reserved for main page heading/ [level=2] + `); + }); + + test('prose container renders nested semantic heading hierarchy', async ({ + page, + }) => { + const root = await gotoTypographyStory( + page, + 'components-typography--prose-container' + ); + await expect(root).toMatchAriaSnapshot(` + - heading "Semantic H1" [level=1] + - heading "Semantic H2" [level=2] + - heading "Semantic H3" [level=3] + - heading "Semantic H4" [level=4] + `); + }); +}); diff --git a/2nd-gen/packages/swc/components/typography/test/typography.test.ts b/2nd-gen/packages/swc/components/typography/test/typography.test.ts new file mode 100644 index 00000000000..18e5976350e --- /dev/null +++ b/2nd-gen/packages/swc/components/typography/test/typography.test.ts @@ -0,0 +1,251 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { html } from 'lit'; +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import meta, { + CodeVariant, + Defaults, + HeadingHeavy, + HeadingVariant, + Playground, +} from '../stories/typography.stories.js'; +import { template } from '../stories/typography.template.js'; + +// This file defines dev-only test stories that reuse the main story metadata. +export default { + ...meta, + title: 'Typography/Tests', + parameters: { + ...meta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// ────────────────────────────────────────────────────────────── +// TEST: Defaults +// ────────────────────────────────────────────────────────────── + +export const PlaygroundTest: Story = { + ...Playground, + play: async ({ canvasElement, step }) => { + await step('renders heading variant at default size M', async () => { + const heading = canvasElement.querySelector('h2'); + expect(heading, 'heading element is rendered').toBeTruthy(); + expect( + heading?.className, + 'heading has base swc-Heading class' + ).toContain('swc-Heading'); + // Size M is the default and does not add a size suffix class + expect( + heading?.className, + 'no explicit size class for default M' + ).not.toContain('--size'); + }); + }, +}; + +export const DefaultsTest: Story = { + ...Defaults, + play: async ({ canvasElement, step }) => { + await step( + 'renders all typography variants at default size M', + async () => { + const headings = canvasElement.querySelectorAll('h2.swc-Heading'); + expect(headings.length, 'heading variant is rendered').toBeGreaterThan( + 0 + ); + + const titleEls = canvasElement.querySelectorAll('[class*="swc-Title"]'); + expect(titleEls.length, 'title variant is rendered').toBeGreaterThan(0); + + const bodyEls = canvasElement.querySelectorAll('[class*="swc-Body"]'); + expect(bodyEls.length, 'body variant is rendered').toBeGreaterThan(0); + + const detailEls = canvasElement.querySelectorAll( + '[class*="swc-Detail"]' + ); + expect(detailEls.length, 'detail variant is rendered').toBeGreaterThan( + 0 + ); + + const codeEls = canvasElement.querySelectorAll('code.swc-Code'); + expect(codeEls.length, 'code variant is rendered').toBeGreaterThan(0); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Properties / Attributes +// ────────────────────────────────────────────────────────────── + +export const HeadingVariantTest: Story = { + ...HeadingVariant, + play: async ({ canvasElement, step }) => { + await step('renders heading at all allowed sizes', async () => { + // Heading allowed sizes: XS, S, M, L, XL, XXL, XXXL, XXXXL = 8 sizes + const headingElements = canvasElement.querySelectorAll('h2.swc-Heading'); + expect(headingElements.length, 'heading renders 8 size variants').toBe(8); + }); + + await step('size M heading has no size suffix class', async () => { + const headingM = canvasElement.querySelector( + 'h2.swc-Heading:not([class*="--size"])' + ); + expect( + headingM, + 'size M heading has no explicit size suffix' + ).toBeTruthy(); + }); + + await step('non-M sizes have explicit size classes', async () => { + const headingXL = canvasElement.querySelector('h2[class*="--sizeXL"]'); + expect(headingXL, 'size XL heading has --sizeXL class').toBeTruthy(); + }); + }, +}; + +export const HeadingHeavyTest: Story = { + ...HeadingHeavy, + play: async ({ canvasElement, step }) => { + await step('renders heading with heavy modifier class', async () => { + const heading = canvasElement.querySelector('h2.swc-Heading'); + expect(heading, 'heading element is rendered').toBeTruthy(); + expect( + heading?.className, + 'heading has the heavy modifier class' + ).toContain('swc-Heading--heavy'); + expect(heading?.className, 'heading has the size L class').toContain( + 'swc-Heading--sizeL' + ); + }); + }, +}; + +export const CodeVariantTest: Story = { + ...CodeVariant, + play: async ({ canvasElement, step }) => { + await step('renders code variant at all allowed sizes', async () => { + // Code allowed sizes: XS, S, M, L, XL = 5 sizes + const codeElements = canvasElement.querySelectorAll('code.swc-Code'); + expect(codeElements.length, 'code renders 5 size variants').toBe(5); + }); + + await step('size M code has no size suffix class', async () => { + const codeM = canvasElement.querySelector( + 'code.swc-Code:not([class*="--size"])' + ); + expect( + codeM, + 'size M code element has no explicit size suffix' + ).toBeTruthy(); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Branches (coverage of template.ts uncovered paths) +// ────────────────────────────────────────────────────────────── + +/** + * Tests that coerceSize falls back to 'M' when the requested size is not + * in the allowed sizes for the given variant. 'XXXL' is a valid size in + * the SIZES array but is not allowed for the 'code' variant (only XS–XL). + * + * Coverage target: coerceSize fallback branch in typography.template.ts + */ +export const InvalidSizeFallbackTest: Story = { + render: () => html` + ${template({ variant: 'code', size: 'XXXL' })} + `, + play: async ({ canvasElement, step }) => { + await step( + 'coerces invalid size XXXL to fallback M for code variant', + async () => { + const codeEl = canvasElement.querySelector('code'); + expect( + codeEl, + 'code element is rendered when XXXL is coerced to M' + ).toBeTruthy(); + expect( + codeEl?.className, + 'code element has base swc-Code class' + ).toContain('swc-Code'); + // Size M does not add a --sizeXXXL class; fallback renders with no size suffix + expect( + codeEl?.className, + 'code element has no --sizeXXXL class' + ).not.toContain('sizeXXXL'); + } + ); + }, +}; + +/** + * Tests that the `heavy && !caps.supportsHeavy` filter in the variants loop + * removes all variants except 'heading' (the only one that supportsHeavy). + * + * Coverage target: filter branch `if (heavy && !caps.supportsHeavy) return false` + * in typography.template.ts + */ +export const HeadingHeavyAllVariantsTest: Story = { + render: () => html` + ${template({ showAllVariants: true, heavy: true })} + `, + play: async ({ canvasElement, step }) => { + await step( + 'renders only heading when showAllVariants=true and heavy=true', + async () => { + // Heading supports heavy → passes the filter + const headings = canvasElement.querySelectorAll('h2.swc-Heading'); + expect( + headings.length, + 'heading is rendered since it supports heavy' + ).toBeGreaterThan(0); + } + ); + + await step( + 'filters out title, body, detail, and code since they do not support heavy', + async () => { + // Title, body, detail render as p; code renders as code + // With heavy=true, all non-heading variants are filtered out + const titleSamples = canvasElement.querySelectorAll('p.swc-Title'); + expect( + titleSamples.length, + 'title variant is filtered out because it does not support heavy' + ).toBe(0); + + const bodySamples = canvasElement.querySelectorAll('p.swc-Body'); + expect( + bodySamples.length, + 'body variant is filtered out because it does not support heavy' + ).toBe(0); + + const detailSamples = canvasElement.querySelectorAll('p.swc-Detail'); + expect( + detailSamples.length, + 'detail variant is filtered out because it does not support heavy' + ).toBe(0); + + const codeSamples = canvasElement.querySelectorAll('code.swc-Code'); + expect( + codeSamples.length, + 'code variant is filtered out because it does not support heavy' + ).toBe(0); + } + ); + }, +};