diff --git a/packages/test/playwright.config.ts b/packages/test/playwright.config.ts index 5107d2649..3f8131d01 100644 --- a/packages/test/playwright.config.ts +++ b/packages/test/playwright.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 4 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/packages/test/src/App.vue b/packages/test/src/App.vue index 879e804b9..6d786c062 100644 --- a/packages/test/src/App.vue +++ b/packages/test/src/App.vue @@ -6,6 +6,7 @@
  • 首页
  • Attachments 组件
  • Container 组件
  • +
  • Layout 组件
  • Sender 组件
  • @@ -22,9 +23,10 @@ import type { Component } from 'vue' import Home from './home/index.vue' import AttachmentsDemo from './attachments/index.vue' import ContainerDemo from './container/index.vue' +import LayoutDemo from './layout/index.vue' import SenderDemo from './sender/index.vue' -type ComponentName = 'Home' | 'Attachments' | 'Container' | 'Sender' +type ComponentName = 'Home' | 'Attachments' | 'Container' | 'Layout' | 'Sender' const currentComponent = ref('Home') @@ -32,6 +34,7 @@ const components: Record = { Home, Attachments: AttachmentsDemo, Container: ContainerDemo, + Layout: LayoutDemo, Sender: SenderDemo, } diff --git a/packages/test/src/layout/fixtures/AsideStateFixtures.vue b/packages/test/src/layout/fixtures/AsideStateFixtures.vue new file mode 100644 index 000000000..e6fb1c2c2 --- /dev/null +++ b/packages/test/src/layout/fixtures/AsideStateFixtures.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/packages/test/src/layout/fixtures/FloatingStateFixtures.vue b/packages/test/src/layout/fixtures/FloatingStateFixtures.vue new file mode 100644 index 000000000..a257b3459 --- /dev/null +++ b/packages/test/src/layout/fixtures/FloatingStateFixtures.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/packages/test/src/layout/fixtures/LayoutCssVarFixtures.vue b/packages/test/src/layout/fixtures/LayoutCssVarFixtures.vue new file mode 100644 index 000000000..2f1efb6ce --- /dev/null +++ b/packages/test/src/layout/fixtures/LayoutCssVarFixtures.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/packages/test/src/layout/helpers/LayoutTestPage.ts b/packages/test/src/layout/helpers/LayoutTestPage.ts new file mode 100644 index 000000000..71aed2432 --- /dev/null +++ b/packages/test/src/layout/helpers/LayoutTestPage.ts @@ -0,0 +1,389 @@ +import { expect, type Locator, type Page } from '@playwright/test' +import { layoutSelectors } from '../selectors' + +type ScopedTarget = Page | Locator + +export type LayoutSide = 'left' | 'right' +export type LayoutMode = 'normal' | 'floating' +export type LayoutAsideMode = 'dock' | 'drawer' +export type LayoutCollapseEffect = 'overlay' | 'slide' +export type LayoutAsideState = 'open' | 'rail' | 'closed' +export type LayoutEventPhase = 'start' | 'progress' | 'end' +export type LayoutFloatingHandle = 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' +export type LayoutFloatingPlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center' + +export interface LayoutAsideResizeLogEntry { + phase: LayoutEventPhase + side: LayoutSide + expandedWidth: number +} + +export interface LayoutFloatingDragLogEntry { + phase: LayoutEventPhase + placement?: LayoutFloatingPlacement + offsetX?: number + offsetY?: number + width?: number + height?: number +} + +export interface LayoutFloatingResizeLogEntry { + phase: LayoutEventPhase + handle: LayoutFloatingHandle + placement?: LayoutFloatingPlacement + offsetX?: number + offsetY?: number + width: number + height: number +} + +export interface LayoutHarnessSnapshot { + metrics: { + leftResizeStart: number + leftResizeEnd: number + rightResizeStart: number + rightResizeEnd: number + floatingDragStart: number + floatingDrag: number + floatingDragEnd: number + floatingResizeStartByHandle: Record + floatingResizeEndByHandle: Record + leftToggleActions: number + rightToggleActions: number + modeToggleActions: number + } + widths: { + left: number + right: number + floating: number + } + messagesCount: number + logs: { + asideResize: LayoutAsideResizeLogEntry[] + floatingDrag: LayoutFloatingDragLogEntry[] + floatingResize: LayoutFloatingResizeLogEntry[] + } +} + +function within(scope: ScopedTarget, selector: string): Locator { + return scope.locator(selector) +} + +function isPage(scope: ScopedTarget): scope is Page { + return 'goto' in scope +} + +export class LayoutTestPage { + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + get surface() { + return this.page.locator('#layout-demo-surface') + } + + get root() { + return this.surface + } + + get main() { + return this.page.locator(layoutSelectors.main) + } + + get scrollTarget() { + return this.page.locator(layoutSelectors.scrollTarget) + } + + get scrollbar() { + return this.page.locator(layoutSelectors.scrollbar) + } + + get scrollbarThumb() { + return this.page.locator(layoutSelectors.scrollbarThumb) + } + + get backdrop() { + return this.page.locator(layoutSelectors.backdrop) + } + + get blockedAsideFixture() { + return this.page.getByTestId('blocked-aside-fixture') + } + + get uncontrolledAsideFixture() { + return this.page.getByTestId('uncontrolled-aside-fixture') + } + + get drawerAsideFixture() { + return this.page.getByTestId('drawer-aside-fixture') + } + + get blockedFloatingSurface() { + return this.page.locator('#blocked-floating-surface') + } + + get uncontrolledFloatingSurface() { + return this.page.locator('#uncontrolled-floating-surface') + } + + getFloatingSurfaceByMarker(marker: string) { + return this.page.locator(`[data-surface-marker="${marker}"]`) + } + + async open() { + await this.page.goto('/') + await this.page.getByRole('link', { name: 'Layout 组件' }).click() + await expect(this.page.getByRole('heading', { level: 2, name: 'Layout 组件测试' })).toBeVisible() + } + + getAside(side: LayoutSide, scope: ScopedTarget = this.page) { + return within(scope, side === 'left' ? layoutSelectors.leftAside : layoutSelectors.rightAside) + } + + getResizeTrigger(side: LayoutSide, scope: ScopedTarget = this.page) { + return within(scope, side === 'left' ? layoutSelectors.leftResizeTrigger : layoutSelectors.rightResizeTrigger) + } + + getFloatingResizeTrigger(handle: LayoutFloatingHandle, scope: ScopedTarget = this.surface) { + const selectorMap: Record = { + s: layoutSelectors.floatingResizeTriggerS, + e: layoutSelectors.floatingResizeTriggerE, + w: layoutSelectors.floatingResizeTriggerW, + ne: layoutSelectors.floatingResizeTriggerNE, + nw: layoutSelectors.floatingResizeTriggerNW, + se: layoutSelectors.floatingResizeTriggerSE, + sw: layoutSelectors.floatingResizeTriggerSW, + } + + return within(scope, selectorMap[handle]) + } + + getFloatingDragBar(scope: ScopedTarget = this.surface) { + return within(scope, layoutSelectors.floatingDragBar) + } + + getBackdrop(scope: ScopedTarget = this.page) { + return within(scope, layoutSelectors.backdrop) + } + + async showAsideFixtures() { + await this.page.getByTestId('show-aside-state-fixtures-btn').click() + await expect(this.blockedAsideFixture).toBeVisible() + await expect(this.uncontrolledAsideFixture).toBeVisible() + await expect(this.drawerAsideFixture).toBeVisible() + } + + async showFloatingFixtures() { + await this.page.getByTestId('show-floating-state-fixtures-btn').click() + await expect(this.blockedFloatingSurface).toBeVisible() + await expect(this.uncontrolledFloatingSurface).toBeVisible() + } + + async showFloatingPlacementFixtures() { + await this.showFloatingFixtures() + await this.page.getByTestId('show-floating-placement-fixtures-btn').click() + await expect(this.getFloatingSurfaceByMarker('placement-top-left')).toBeVisible() + } + + async showCssVarFixtures() { + await this.page.getByTestId('show-css-var-fixtures-btn').click() + await expect(this.page.getByTestId('css-vars-normal-surface')).toBeVisible() + } + + async setMode(mode: LayoutMode) { + await this.page.getByTestId(`mode-${mode}-btn`).click() + } + + async setAsideMode(side: LayoutSide, mode: LayoutAsideMode) { + await this.page.getByTestId(`${side}-mode-${mode}-btn`).click() + } + + async toggleAside(side: LayoutSide) { + await this.page.getByTestId(`${side}-toggle-btn`).click() + } + + async collapseAside(side: LayoutSide) { + await this.page.getByTestId(`${side}-collapse-btn`).click() + } + + async setCollapseEffect(side: LayoutSide, effect: LayoutCollapseEffect) { + await this.page.getByTestId(`${side}-effect-${effect}-btn`).click() + } + + async disableAsideResizable(side: LayoutSide) { + await this.page.getByTestId(`${side}-resizable-off-btn`).click() + } + + async setLeftCollapsedWidthZero() { + await this.page.getByTestId('left-collapsed-width-zero-btn').click() + } + + async appendMessages() { + await this.page.getByTestId('append-messages-btn').click() + } + + async resetMessagesToShortList() { + await this.page.getByTestId('short-messages-btn').click() + } + + async omitConditionalSlots() { + await this.page.getByTestId('conditional-slots-omit-btn').click() + } + + async disableFloatingResizable() { + await this.page.getByTestId('floating-resizable-off-btn').click() + } + + async disableFloatingDraggable() { + await this.page.getByTestId('floating-draggable-off-btn').click() + } + + async resetFloating() { + await this.page.getByTestId('reset-floating-btn').click() + } + + async expectSurfaceMode(mode: LayoutMode, scope: ScopedTarget = this.page) { + const target = isPage(scope) ? this.root : scope + const floatingClassPattern = /(^|\s)tr-layout--floating(\s|$)/ + + if (mode === 'floating') { + await expect(target).toHaveClass(floatingClassPattern) + return + } + + await expect(target).not.toHaveClass(floatingClassPattern) + } + + async expectAsideMode(side: LayoutSide, mode: LayoutAsideMode, scope: ScopedTarget = this.page) { + await expect(this.getAside(side, scope)).toHaveClass(new RegExp(`tr-layout__aside--${mode}`)) + } + + async expectAsideState(side: LayoutSide, state: LayoutAsideState, scope: ScopedTarget = this.page) { + const target = this.getAside(side, scope) + + await expect + .poll(async () => + target.evaluate((element) => { + if (element.classList.contains('tr-layout__aside--expanded')) { + return 'open' + } + + if (element.classList.contains('tr-layout__aside--rail')) { + return 'rail' + } + + return 'closed' + }), + ) + .toBe(state) + } + + async expectCollapseEffect(side: LayoutSide, effect: LayoutCollapseEffect, scope: ScopedTarget = this.page) { + await expect(this.getAside(side, scope)).toHaveClass(new RegExp(`tr-layout__aside--effect-${effect}`)) + } + + async expectBackdropState(state: 'open' | 'closed', scope: ScopedTarget = this.page) { + if (state === 'open') { + await expect(this.getBackdrop(scope)).toBeVisible() + return + } + + await expect(this.getBackdrop(scope)).toHaveCount(0) + } + + async readHarness() { + await expect.poll(async () => this.page.evaluate(() => Boolean(window.__TR_LAYOUT_HARNESS__))).toBe(true) + return this.page.evaluate(() => window.__TR_LAYOUT_HARNESS__!) as Promise + } + + async getBox(locator: Locator) { + const box = await locator.boundingBox() + + if (!box) { + throw new Error('Missing bounding box') + } + + return box + } + + async getWidth(locator: Locator) { + const box = await this.getBox(locator) + return box.width + } + + async dragSurface(deltaX: number, deltaY: number, scope: ScopedTarget = this.surface) { + await this.dragBy(this.getFloatingDragBar(scope), deltaX, deltaY) + } + + async resizeSurface(handle: LayoutFloatingHandle, deltaX: number, deltaY = 0, scope: ScopedTarget = this.surface) { + await this.dragFloatingResizeHandle(this.getFloatingResizeTrigger(handle, scope), handle, deltaX, deltaY) + } + + async resizeAside(side: LayoutSide, deltaX: number, scope: ScopedTarget = this.page) { + await this.dragResizeHandle(this.getResizeTrigger(side, scope), deltaX) + } + + async dragBy(locator: Locator, deltaX: number, deltaY = 0) { + await expect(locator).toBeVisible() + await locator.hover() + const box = await locator.boundingBox() + + if (!box) { + throw new Error('Missing drag target') + } + + const startX = box.x + box.width / 2 + const startY = box.y + box.height / 2 + + await this.page.mouse.move(startX, startY) + await this.page.mouse.down() + await this.page.mouse.move(startX + deltaX, startY + deltaY, { steps: 20 }) + await this.page.mouse.up() + } + + async dragResizeHandle(locator: Locator, deltaX: number) { + await expect(locator).toBeVisible() + await locator.hover() + const box = await locator.boundingBox() + + if (!box) { + throw new Error('Missing resize handle') + } + + const startX = box.x + box.width - 1 + const startY = box.y + box.height / 2 + + await this.page.mouse.move(startX, startY) + await this.page.mouse.down() + await this.page.mouse.move(startX + deltaX, startY, { steps: 24 }) + await this.page.mouse.up() + } + + async dragFloatingResizeHandle(locator: Locator, handle: LayoutFloatingHandle, deltaX: number, deltaY: number) { + await expect(locator).toBeVisible() + await locator.hover() + const box = await locator.boundingBox() + + if (!box) { + throw new Error('Missing surface resize handle') + } + + const startX = handle.includes('w') + ? box.x + box.width - 1 + : handle.includes('e') + ? box.x + 1 + : box.x + box.width / 2 + const startY = handle.includes('n') + ? box.y + box.height - 1 + : handle.includes('s') + ? box.y + 1 + : box.y + box.height / 2 + + await this.page.mouse.move(startX, startY) + await this.page.mouse.down() + await this.page.mouse.move(startX + deltaX, startY + deltaY, { steps: 24 }) + await this.page.mouse.up() + } +} diff --git a/packages/test/src/layout/helpers/index.ts b/packages/test/src/layout/helpers/index.ts new file mode 100644 index 000000000..35c3ac99a --- /dev/null +++ b/packages/test/src/layout/helpers/index.ts @@ -0,0 +1,21 @@ +import { expect, test as base } from '@playwright/test' +import { LayoutTestPage } from './LayoutTestPage' + +export { expect, LayoutTestPage } +export type { + LayoutAsideMode, + LayoutAsideState, + LayoutCollapseEffect, + LayoutFloatingHandle, + LayoutHarnessSnapshot, + LayoutMode, + LayoutSide, +} from './LayoutTestPage' + +export const test = base.extend<{ layout: LayoutTestPage }>({ + layout: async ({ page }, use) => { + const layout = new LayoutTestPage(page) + await layout.open() + await use(layout) + }, +}) diff --git a/packages/test/src/layout/index.vue b/packages/test/src/layout/index.vue new file mode 100644 index 000000000..f3fe1052c --- /dev/null +++ b/packages/test/src/layout/index.vue @@ -0,0 +1,616 @@ + + + + + diff --git a/packages/test/src/layout/selectors.ts b/packages/test/src/layout/selectors.ts new file mode 100644 index 000000000..977d6ddcd --- /dev/null +++ b/packages/test/src/layout/selectors.ts @@ -0,0 +1,25 @@ +export const layoutSelectors = { + pageTitle: 'h2', + root: '.tr-layout', + body: '.tr-layout__body', + leftAside: '.tr-layout__aside.tr-layout__aside--left', + rightAside: '.tr-layout__aside.tr-layout__aside--right', + header: '.tr-layout__header', + main: '.tr-layout__main', + scrollTarget: '[data-testid="layout-scroll-target"]', + footer: '.tr-layout__footer', + scrollbar: '.tr-layout-proxy-scrollbar', + scrollbarThumb: '.tr-layout-proxy-scrollbar__thumb', + surface: '.tr-layout', + floatingDragBar: '.tr-layout__drag-bar', + backdrop: '.tr-layout__backdrop', + leftResizeTrigger: '.tr-layout__resize-trigger--left', + rightResizeTrigger: '.tr-layout__resize-trigger--right', + floatingResizeTriggerS: '.tr-layout__floating-resize-trigger--s', + floatingResizeTriggerE: '.tr-layout__floating-resize-trigger--e', + floatingResizeTriggerW: '.tr-layout__floating-resize-trigger--w', + floatingResizeTriggerNE: '.tr-layout__floating-resize-trigger--ne', + floatingResizeTriggerNW: '.tr-layout__floating-resize-trigger--nw', + floatingResizeTriggerSE: '.tr-layout__floating-resize-trigger--se', + floatingResizeTriggerSW: '.tr-layout__floating-resize-trigger--sw', +} diff --git a/packages/test/src/layout/specs/aside.spec.ts b/packages/test/src/layout/specs/aside.spec.ts new file mode 100644 index 000000000..4672dcfe0 --- /dev/null +++ b/packages/test/src/layout/specs/aside.spec.ts @@ -0,0 +1,280 @@ +import { expect, test } from '../helpers' + +test.describe('Layout 组件测试 - Aside', () => { + test('Props: leftAside / rightAside mode - 应支持 dock 与 drawer 切换', async ({ layout }) => { + await layout.setAsideMode('left', 'drawer') + await layout.expectAsideMode('left', 'drawer') + + await layout.setAsideMode('left', 'dock') + await layout.expectAsideMode('left', 'dock') + + await layout.setAsideMode('right', 'dock') + await layout.expectAsideMode('right', 'dock') + + await layout.setAsideMode('right', 'drawer') + await layout.expectAsideMode('right', 'drawer') + }) + + test('Controlled state: 外部 open 改变后 UI 应同步', async ({ layout }) => { + await layout.toggleAside('left') + let harness = await layout.readHarness() + expect(harness.metrics.leftToggleActions).toBe(1) + await layout.expectAsideState('left', 'rail') + + await layout.toggleAside('right') + harness = await layout.readHarness() + expect(harness.metrics.rightToggleActions).toBe(1) + await layout.expectAsideState('right', 'open') + }) + + test('Drawer: backdrop - 应正确打开和关闭右侧 drawer', async ({ layout }) => { + await layout.setAsideMode('right', 'drawer') + await layout.toggleAside('right') + + await layout.expectBackdropState('open') + await layout.backdrop.click() + await layout.expectBackdropState('closed') + }) + + test('Props: collapseEffect - slide 时 aside-content 应暴露稳定效果契约', async ({ layout }) => { + await layout.setCollapseEffect('left', 'slide') + await layout.collapseAside('left') + await layout.expectCollapseEffect('left', 'slide') + + await layout.setAsideMode('right', 'dock') + await layout.setCollapseEffect('right', 'slide') + await layout.collapseAside('right') + await layout.expectCollapseEffect('right', 'slide') + }) + + test('Props: resizable / Emits: aside-resize* - 改宽应生效,并保证 end 与最后一次 progress 对齐', async ({ + layout, + }) => { + await layout.setAsideMode('left', 'dock') + await layout.setAsideMode('right', 'dock') + await layout.toggleAside('right') + await layout.expectAsideState('left', 'open') + await layout.expectAsideState('right', 'open') + await expect(layout.getResizeTrigger('left')).toBeVisible() + await expect(layout.getResizeTrigger('right')).toBeVisible() + + const beforeLeft = (await layout.readHarness()).widths.left + await layout.resizeAside('left', 160) + + let harness = await layout.readHarness() + const leftLogs = harness.logs.asideResize.filter((entry) => entry.side === 'left') + const leftProgressLogs = leftLogs.filter((entry) => entry.phase === 'progress') + const leftEndLog = leftLogs[leftLogs.length - 1] + const leftLastProgressLog = leftProgressLogs[leftProgressLogs.length - 1] + + expect(harness.metrics.leftResizeStart).toBe(1) + expect(harness.metrics.leftResizeEnd).toBe(1) + expect(harness.widths.left).toBeGreaterThanOrEqual(beforeLeft) + expect(leftLogs[0]?.phase).toBe('start') + expect(leftEndLog?.phase).toBe('end') + expect(leftEndLog?.expandedWidth).toBe(leftLastProgressLog?.expandedWidth) + + const beforeRight = harness.widths.right + await layout.resizeAside('right', -160) + + harness = await layout.readHarness() + const rightLogs = harness.logs.asideResize.filter((entry) => entry.side === 'right') + const rightProgressLogs = rightLogs.filter((entry) => entry.phase === 'progress') + const rightEndLog = rightLogs[rightLogs.length - 1] + const rightLastProgressLog = rightProgressLogs[rightProgressLogs.length - 1] + + expect(harness.metrics.rightResizeStart).toBe(1) + expect(harness.metrics.rightResizeEnd).toBe(1) + expect(harness.widths.right).toBeGreaterThanOrEqual(beforeRight) + expect(rightLogs[0]?.phase).toBe('start') + expect(rightEndLog?.phase).toBe('end') + expect(rightEndLog?.expandedWidth).toBe(rightLastProgressLog?.expandedWidth) + }) + + test('Dock: main min width - 双侧展开时主区不应被压穿', async ({ layout }) => { + await layout.setAsideMode('right', 'dock') + await layout.toggleAside('right') + + await expect + .poll(async () => { + const box = await layout.main.boundingBox() + return box?.width ?? 0 + }) + .toBeGreaterThanOrEqual(320) + }) + + test('Rail: collapsedWidth=0 - 收起后应完全隐藏', async ({ layout }) => { + await layout.setLeftCollapsedWidthZero() + await layout.collapseAside('left') + + await layout.expectAsideState('left', 'closed') + await expect(layout.getAside('left')).toHaveAttribute('inert', '') + }) + + test('Props: resizable=false - 应隐藏 resize trigger', async ({ layout }) => { + await layout.disableAsideResizable('left') + await layout.setAsideMode('right', 'dock') + await layout.disableAsideResizable('right') + + await expect(layout.getResizeTrigger('left')).toHaveCount(0) + await expect(layout.getResizeTrigger('right')).toHaveCount(0) + await expect(layout.getAside('left')).not.toHaveAttribute('data-resizable', '') + await expect(layout.getAside('right')).not.toHaveAttribute('data-resizable', '') + }) + + test('Controlled props: open - 受控父级不回写时应只发事件,不自改 UI', async ({ layout }) => { + await layout.showAsideFixtures() + + const fixture = layout.blockedAsideFixture + + await expect(fixture.getByTestId('blocked-open-state')).toHaveText('open') + await fixture.getByTestId('blocked-toggle').click() + + await expect(layout.page.getByTestId('blocked-open-events')).toHaveText('1') + await expect(layout.page.getByTestId('blocked-last-open')).toHaveText('false') + await expect(fixture.getByTestId('blocked-open-state')).toHaveText('open') + await layout.expectAsideState('left', 'open', fixture) + }) + + test('Controlled props: expandedWidth - 受控父级不回写时应只发事件,并保持 prop 宽度优先', async ({ layout }) => { + await layout.showAsideFixtures() + + const fixture = layout.blockedAsideFixture + const leftAside = layout.getAside('left', fixture) + + const beforeWidth = await layout.getWidth(leftAside) + expect(beforeWidth).toBeGreaterThanOrEqual(256) + expect(beforeWidth).toBeLessThanOrEqual(264) + + await layout.resizeAside('left', 160, fixture) + + await expect + .poll(async () => Number(await layout.page.getByTestId('blocked-width-events').textContent())) + .toBeGreaterThan(0) + await expect + .poll(async () => Number(await layout.page.getByTestId('blocked-last-width').textContent())) + .toBeGreaterThan(260) + + const afterWidth = await layout.getWidth(leftAside) + expect(Math.abs(afterWidth - beforeWidth)).toBeLessThan(2) + }) + + test('Default props: defaultOpen / defaultExpandedWidth - 非受控 aside 应以内建状态启动', async ({ layout }) => { + await layout.showAsideFixtures() + + const fixture = layout.uncontrolledAsideFixture + const leftAside = layout.getAside('left', fixture) + + await expect(fixture.getByTestId('uncontrolled-open-state')).toHaveText('open') + + await layout.expectAsideState('left', 'open', fixture) + + const width = await layout.getWidth(leftAside) + expect(width).toBeGreaterThanOrEqual(286) + expect(width).toBeLessThanOrEqual(294) + }) + + test('Default props: defaultOpen / collapsedWidth - 非受控 aside 收起后应保留 rail', async ({ layout }) => { + await layout.showAsideFixtures() + + const fixture = layout.uncontrolledAsideFixture + const leftAside = layout.getAside('left', fixture) + + await fixture.getByTestId('uncontrolled-toggle').click() + + await expect(layout.page.getByTestId('uncontrolled-open-events')).toHaveText('1') + await expect(fixture.getByTestId('uncontrolled-open-state')).toHaveText('closed') + await layout.expectAsideState('left', 'rail', fixture) + + await expect.poll(async () => await layout.getWidth(leftAside)).toBeGreaterThanOrEqual(48) + await expect.poll(async () => await layout.getWidth(leftAside)).toBeLessThanOrEqual(56) + }) + + test('Default props: 初始化后更新 defaultOpen / defaultExpandedWidth 不应重新同步', async ({ layout }) => { + await layout.showAsideFixtures() + + const fixture = layout.uncontrolledAsideFixture + const leftAside = layout.getAside('left', fixture) + const beforeWidth = await layout.getWidth(leftAside) + + await expect(fixture.getByTestId('uncontrolled-open-state')).toHaveText('open') + await fixture.getByTestId('uncontrolled-default-update-btn').click() + + await expect(fixture.getByTestId('uncontrolled-open-state')).toHaveText('open') + await layout.expectAsideState('left', 'open', fixture) + + const afterWidth = await layout.getWidth(leftAside) + expect(Math.abs(afterWidth - beforeWidth)).toBeLessThan(2) + }) + + test('Default props: defaultExpandedWidth / minExpandedWidth / maxExpandedWidth - 非受控 resize 应更新内部宽度并 obey clamp', async ({ + layout, + }) => { + await layout.showAsideFixtures() + + const fixture = layout.uncontrolledAsideFixture + const leftAside = layout.getAside('left', fixture) + + await layout.resizeAside('left', 200, fixture) + await expect + .poll(async () => Number(await layout.page.getByTestId('uncontrolled-width-events').textContent())) + .toBeGreaterThan(0) + + const maxClampedWidth = await layout.getWidth(leftAside) + expect(maxClampedWidth).toBeGreaterThanOrEqual(336) + expect(maxClampedWidth).toBeLessThanOrEqual(344) + + await layout.resizeAside('left', -400, fixture) + const minClampedWidth = await layout.getWidth(leftAside) + expect(minClampedWidth).toBeGreaterThanOrEqual(236) + expect(minClampedWidth).toBeLessThanOrEqual(244) + }) + + test('Controlled 判定: open / expandedWidth = undefined 时应退回 default* 非受控语义', async ({ layout }) => { + await layout.showAsideFixtures() + + const fixture = layout.page.getByTestId('undefined-aside-fixture') + const leftAside = layout.getAside('left', fixture) + + await expect(fixture.getByTestId('undefined-open-state')).toHaveText('open') + await expect(fixture.getByTestId('undefined-last-open')).toHaveText('open') + + const beforeWidth = await layout.getWidth(leftAside) + expect(beforeWidth).toBeGreaterThanOrEqual(302) + expect(beforeWidth).toBeLessThanOrEqual(310) + + await fixture.getByTestId('undefined-toggle').click() + + await expect(fixture.getByTestId('undefined-open-state')).toHaveText('closed') + await expect(fixture.getByTestId('undefined-last-open')).toHaveText('closed') + await layout.expectAsideState('left', 'rail', fixture) + }) + + test('CSS vars: drawer width - 应按实例变量生效,且双 drawer 打开时保持互斥', async ({ layout }) => { + await layout.showAsideFixtures() + + const fixture = layout.drawerAsideFixture + const leftAside = layout.getAside('left', fixture) + const rightAside = layout.getAside('right', fixture) + + await fixture.getByTestId('drawer-left-toggle').click() + await expect(fixture.getByTestId('drawer-left-state')).toHaveText('open') + await layout.expectBackdropState('open', fixture) + await layout.expectAsideState('left', 'open', fixture) + + const leftWidth = await layout.getWidth(leftAside) + expect(leftWidth).toBeGreaterThanOrEqual(340) + expect(leftWidth).toBeLessThanOrEqual(348) + + await fixture.getByTestId('drawer-right-toggle').click() + await expect(fixture.getByTestId('drawer-right-state')).toHaveText('open') + await expect(fixture.getByTestId('drawer-left-state')).toHaveText('closed') + await layout.expectAsideState('left', 'closed', fixture) + await layout.expectAsideState('right', 'open', fixture) + await expect(leftAside).toHaveAttribute('inert', '') + + const rightWidth = await layout.getWidth(rightAside) + expect(rightWidth).toBeGreaterThanOrEqual(340) + expect(rightWidth).toBeLessThanOrEqual(348) + }) +}) diff --git a/packages/test/src/layout/specs/css-vars.spec.ts b/packages/test/src/layout/specs/css-vars.spec.ts new file mode 100644 index 000000000..2cd294915 --- /dev/null +++ b/packages/test/src/layout/specs/css-vars.spec.ts @@ -0,0 +1,137 @@ +import type { Locator } from '@playwright/test' +import { expect, test } from '../helpers' +import { layoutSelectors } from '../selectors' + +async function readStyle(locator: Locator, prop: string) { + return locator.evaluate((el, name) => getComputedStyle(el).getPropertyValue(name).trim(), prop) +} + +async function getBox(locator: Locator) { + const box = await locator.boundingBox() + + if (!box) { + throw new Error('Missing bounding box') + } + + return box +} + +test.describe('Layout 组件测试 - CSS Variables', () => { + test.beforeEach(async ({ layout }) => { + await layout.showCssVarFixtures() + await layout.expectSurfaceMode('floating', layout.page.getByTestId('css-vars-floating-surface')) + }) + + test('公开变量: --tr-layout-height / --tr-layout-bg - 应影响 surface 实际高度和背景', async ({ page }) => { + const surface = page.getByTestId('css-vars-normal-surface') + const body = surface.locator(layoutSelectors.body) + const box = await getBox(surface) + + expect(box.height).toBeGreaterThanOrEqual(418) + expect(box.height).toBeLessThanOrEqual(422) + await expect.poll(async () => readStyle(body, 'background-color')).toBe('rgb(244, 246, 251)') + }) + + test('公开变量: 区域背景和分隔线颜色 - 应作用于 left/right/header/main/footer', async ({ page }) => { + const surface = page.getByTestId('css-vars-normal-surface') + const leftAside = surface.locator(layoutSelectors.leftAside) + const rightAside = surface.locator(layoutSelectors.rightAside) + const header = surface.locator(layoutSelectors.header) + const main = surface.locator(layoutSelectors.main) + const footer = surface.locator(layoutSelectors.footer) + + await expect.poll(async () => readStyle(leftAside, 'background-color')).toBe('rgb(255, 244, 229)') + await expect.poll(async () => readStyle(rightAside, 'background-color')).toBe('rgb(232, 245, 255)') + await expect.poll(async () => readStyle(header, 'background-color')).toBe('rgb(224, 242, 254)') + await expect.poll(async () => readStyle(main, 'background-color')).toBe('rgb(245, 250, 255)') + await expect.poll(async () => readStyle(footer, 'background-color')).toBe('rgb(232, 245, 233)') + await expect.poll(async () => readStyle(leftAside, 'border-right-color')).toBe('rgb(123, 134, 156)') + await expect.poll(async () => readStyle(rightAside, 'border-left-color')).toBe('rgb(123, 134, 156)') + }) + + test('公开变量: --tr-layout-main-min-width - 应保护主区最小宽度', async ({ page }) => { + const surface = page.getByTestId('css-vars-main-min-surface') + const main = surface.locator(layoutSelectors.main) + const box = await getBox(main) + + expect(box.width).toBeGreaterThanOrEqual(300) + }) + + test('公开变量: overlay / panel-shadow - 应作用于右侧 drawer,且不影响左侧 dock', async ({ page }) => { + const surface = page.getByTestId('css-vars-right-drawer-surface') + const leftDock = surface.locator(layoutSelectors.leftAside) + const rightDrawer = surface.locator(layoutSelectors.rightAside) + const backdrop = surface.locator(layoutSelectors.backdrop) + + await expect(rightDrawer).toBeVisible() + + const leftDockBox = await getBox(leftDock) + const rightDrawerBox = await getBox(rightDrawer) + + expect(leftDockBox.width).toBeGreaterThanOrEqual(310) + expect(leftDockBox.width).toBeLessThanOrEqual(314) + expect(rightDrawerBox.width).toBeGreaterThanOrEqual(366) + expect(rightDrawerBox.width).toBeLessThanOrEqual(370) + await expect.poll(async () => readStyle(backdrop, 'background-color')).toBe('rgba(10, 20, 30, 0.45)') + await expect.poll(async () => readStyle(rightDrawer, 'box-shadow')).toContain('rgb(17, 34, 51)') + }) + + test('公开变量: --tr-layout-drawer-width - 应作用于左侧 drawer', async ({ page }) => { + const surface = page.getByTestId('css-vars-left-drawer-surface') + const leftDrawer = surface.locator(layoutSelectors.leftAside) + + await expect(leftDrawer).toBeVisible() + + const box = await getBox(leftDrawer) + expect(box.width).toBeGreaterThanOrEqual(334) + expect(box.width).toBeLessThanOrEqual(338) + }) + + test('公开变量: floating surface - 应作用于圆角、阴影、层级和背景', async ({ page }) => { + const surface = page.getByTestId('css-vars-floating-surface') + const body = surface.locator(layoutSelectors.body) + + await expect.poll(async () => readStyle(body, 'background-color')).toBe('rgb(252, 248, 240)') + await expect.poll(async () => readStyle(surface, 'border-top-left-radius')).toBe('18px') + await expect.poll(async () => readStyle(surface, 'box-shadow')).toContain('rgb(11, 22, 33)') + await expect.poll(async () => readStyle(surface, 'z-index')).toBe('2048') + }) + + test('公开变量: scrollbar - 应作用于轨道宽度和 thumb 默认/hover/active 颜色', async ({ page }) => { + const surface = page.getByTestId('css-vars-scrollbar-surface') + const scrollTarget = surface.getByTestId('css-vars-scroll-target') + const scrollbar = surface.locator(layoutSelectors.scrollbar) + const thumb = surface.locator(layoutSelectors.scrollbarThumb) + + await scrollTarget.hover() + await expect(scrollbar).toBeVisible() + await expect.poll(async () => readStyle(scrollbar, 'width')).toBe('14px') + await expect.poll(async () => readStyle(thumb, 'background-color')).toBe('rgb(120, 130, 150)') + + await thumb.hover() + await expect.poll(async () => readStyle(thumb, 'background-color')).toBe('rgb(90, 100, 120)') + + const thumbBox = await getBox(thumb) + const centerX = thumbBox.x + thumbBox.width / 2 + const centerY = thumbBox.y + thumbBox.height / 2 + + await page.mouse.move(centerX, centerY) + await page.mouse.down() + await expect.poll(async () => readStyle(thumb, 'background-color')).toBe('rgb(60, 70, 90)') + await page.mouse.up() + }) + + test('guard: 旧变量不应再作为公开契约生效', async ({ page }) => { + const surface = page.getByTestId('css-vars-legacy-surface') + const leftAside = surface.locator(layoutSelectors.leftAside) + const header = surface.locator(layoutSelectors.header) + + const leftBox = await getBox(leftAside) + + expect(leftBox.width).toBeGreaterThanOrEqual(278) + expect(leftBox.width).toBeLessThanOrEqual(282) + await expect.poll(async () => readStyle(header, 'padding-left')).toBe('0px') + await expect.poll(async () => readStyle(surface, 'border-top-left-radius')).toBe('0px') + await expect.poll(async () => readStyle(surface, 'box-shadow')).toBe('none') + }) +}) diff --git a/packages/test/src/layout/specs/floating.spec.ts b/packages/test/src/layout/specs/floating.spec.ts new file mode 100644 index 000000000..70e0c2a85 --- /dev/null +++ b/packages/test/src/layout/specs/floating.spec.ts @@ -0,0 +1,417 @@ +import { expect, test, type LayoutFloatingHandle } from '../helpers' + +const placementCases = [ + { marker: 'placement-top-left', xCheck: 'left', yCheck: 'top', edgeOffset: { x: 16, y: 16 } }, + { marker: 'placement-top-right', xCheck: 'right', yCheck: 'top', edgeOffset: { x: 20, y: 36 } }, + { marker: 'placement-bottom-left', xCheck: 'left', yCheck: 'bottom', edgeOffset: { x: 28, y: 44 } }, + { marker: 'placement-bottom-right', xCheck: 'right', yCheck: 'bottom', edgeOffset: { x: 32, y: 32 } }, + { marker: 'placement-center', xCheck: 'center', yCheck: 'center' }, +] as const + +const resizeCases: Array<{ + handle: LayoutFloatingHandle + deltaX: number + deltaY: number + assert: ( + before: { x: number; y: number; width: number; height: number }, + after: { x: number; y: number; width: number; height: number }, + ) => void +}> = [ + { + handle: 'e', + deltaX: 80, + deltaY: 0, + assert: (before, after) => { + expect(after.width).toBeGreaterThan(before.width) + expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(2) + }, + }, + { + handle: 'w', + deltaX: 80, + deltaY: 0, + assert: (before, after) => { + expect(after.width).toBeLessThan(before.width) + expect(after.x).toBeGreaterThan(before.x) + }, + }, + { + handle: 's', + deltaX: 0, + deltaY: 80, + assert: (before, after) => { + expect(after.height).toBeGreaterThan(before.height) + expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(2) + }, + }, + { + handle: 'ne', + deltaX: 80, + deltaY: 80, + assert: (before, after) => { + expect(after.width).toBeGreaterThan(before.width) + expect(after.height).toBeLessThan(before.height) + expect(after.y).toBeGreaterThan(before.y) + }, + }, + { + handle: 'nw', + deltaX: 80, + deltaY: 80, + assert: (before, after) => { + expect(after.width).toBeLessThan(before.width) + expect(after.height).toBeLessThan(before.height) + expect(after.x).toBeGreaterThan(before.x) + expect(after.y).toBeGreaterThan(before.y) + }, + }, + { + handle: 'se', + deltaX: 80, + deltaY: 80, + assert: (before, after) => { + expect(after.width).toBeGreaterThan(before.width) + expect(after.height).toBeGreaterThan(before.height) + }, + }, + { + handle: 'sw', + deltaX: 80, + deltaY: 80, + assert: (before, after) => { + expect(after.width).toBeLessThan(before.width) + expect(after.height).toBeGreaterThan(before.height) + expect(after.x).toBeGreaterThan(before.x) + }, + }, +] as const + +test.describe('Layout 组件测试 - Floating', () => { + test('Props: mode - 应支持 normal 与 floating 切换', async ({ layout }) => { + await layout.setMode('floating') + let harness = await layout.readHarness() + expect(harness.metrics.modeToggleActions).toBe(1) + await layout.expectSurfaceMode('floating') + + await layout.setMode('normal') + harness = await layout.readHarness() + expect(harness.metrics.modeToggleActions).toBe(2) + await layout.expectSurfaceMode('normal') + }) + + test('Props: defaultFloatingState - 5 种 placement 初始化应正确', async ({ layout }) => { + await layout.showFloatingPlacementFixtures() + + const viewport = layout.page.viewportSize() + if (!viewport) { + throw new Error('Missing viewport size') + } + + for (const placementCase of placementCases) { + const surface = layout.getFloatingSurfaceByMarker(placementCase.marker) + const box = await layout.getBox(surface) + + if (placementCase.xCheck === 'left') { + expect(box.x).toBeLessThan(120) + expect(Math.abs(box.x - placementCase.edgeOffset.x)).toBeLessThanOrEqual(2) + } else if (placementCase.xCheck === 'right') { + expect(box.x + box.width).toBeGreaterThan(viewport.width - 120) + expect(Math.abs(viewport.width - box.x - box.width - placementCase.edgeOffset.x)).toBeLessThanOrEqual(2) + } else { + const centerX = box.x + box.width / 2 + expect(Math.abs(centerX - viewport.width / 2)).toBeLessThanOrEqual(4) + } + + if (placementCase.yCheck === 'top') { + expect(box.y).toBeLessThan(120) + expect(Math.abs(box.y - placementCase.edgeOffset.y)).toBeLessThanOrEqual(2) + } else if (placementCase.yCheck === 'bottom') { + expect(box.y + box.height).toBeGreaterThan(viewport.height - 120) + expect(Math.abs(viewport.height - box.y - box.height - placementCase.edgeOffset.y)).toBeLessThanOrEqual(2) + } else { + const centerY = box.y + box.height / 2 + expect(Math.abs(centerY - viewport.height / 2)).toBeLessThanOrEqual(4) + } + } + }) + + test('Events: floating-drag* - 拖拽应公开三阶段事件,并保证 end 与最后一次 progress 对齐', async ({ layout }) => { + await layout.setMode('floating') + await layout.dragSurface(80, 40) + + const harness = await layout.readHarness() + const startLog = harness.logs.floatingDrag[0] + const phases = harness.logs.floatingDrag.map((entry) => entry.phase) + const progressLogs = harness.logs.floatingDrag.filter((entry) => entry.phase === 'progress') + const endLog = harness.logs.floatingDrag[harness.logs.floatingDrag.length - 1] + const lastProgressLog = progressLogs[progressLogs.length - 1] + + expect(phases[0]).toBe('start') + expect(progressLogs.length).toBeGreaterThan(0) + expect(startLog?.placement).toBe('top-left') + expect(startLog?.offsetX).toBe(96) + expect(startLog?.offsetY).toBe(72) + expect(endLog?.phase).toBe('end') + expect(endLog?.placement).toBe('top-left') + expect(endLog?.offsetX).toBe(lastProgressLog?.offsetX) + expect(endLog?.offsetY).toBe(lastProgressLog?.offsetY) + expect(endLog?.width).toBe(lastProgressLog?.width) + expect(endLog?.height).toBe(lastProgressLog?.height) + expect(endLog?.offsetX ?? 0).toBeGreaterThan(startLog?.offsetX ?? 0) + expect(endLog?.offsetY ?? 0).toBeGreaterThan(startLog?.offsetY ?? 0) + expect(harness.metrics.floatingDragStart).toBe(1) + expect(harness.metrics.floatingDragEnd).toBe(1) + expect(harness.metrics.floatingDrag).toBeGreaterThan(0) + }) + + test('Props: draggable - floating 拖拽应更新位置', async ({ layout }) => { + await layout.setMode('floating') + const before = await layout.getBox(layout.surface) + + await layout.dragSurface(80, 40) + const after = await layout.getBox(layout.surface) + + expect(after.x).toBeGreaterThan(before.x) + expect(after.y).toBeGreaterThan(before.y) + }) + + test('Props: draggable - 外部重置位置后再次拖拽不应使用过期 anchor', async ({ layout }) => { + await layout.setMode('floating') + + await layout.dragSurface(180, 100) + await layout.resetFloating() + + const before = await layout.getBox(layout.surface) + + await layout.dragSurface(20, 0) + + const after = await layout.getBox(layout.surface) + + expect(after.x - before.x).toBeGreaterThan(8) + expect(after.x - before.x).toBeLessThan(40) + expect(Math.abs(after.y - before.y)).toBeLessThan(8) + }) + + test('Props: draggable=false - drag bar 不应渲染', async ({ layout }) => { + await layout.setMode('floating') + await layout.disableFloatingDraggable() + + await expect(layout.getFloatingDragBar()).toHaveCount(0) + }) + + test('Interaction lock - resize 时 drag bar 应暂时失去 draggable 状态并在结束后恢复', async ({ layout }) => { + await layout.setMode('floating') + + const dragBar = layout.getFloatingDragBar() + const resizeHandle = layout.getFloatingResizeTrigger('e') + const resizeBox = await layout.getBox(resizeHandle) + + await expect(dragBar).toHaveClass(/tr-layout__drag-bar--draggable/) + + const startX = resizeBox.x + 1 + const startY = resizeBox.y + resizeBox.height / 2 + + await layout.page.mouse.move(startX, startY) + await layout.page.mouse.down() + await layout.page.mouse.move(startX + 40, startY, { steps: 12 }) + + await expect(dragBar).not.toHaveClass(/tr-layout__drag-bar--draggable/) + + await layout.page.mouse.up() + + await expect(dragBar).toHaveClass(/tr-layout__drag-bar--draggable/) + }) + + test('Props: resizable=false - 应隐藏 7 个 floating resize trigger', async ({ layout }) => { + await layout.setMode('floating') + await layout.disableFloatingResizable() + + for (const handle of ['s', 'e', 'w', 'ne', 'nw', 'se', 'sw'] as const) { + await expect(layout.getFloatingResizeTrigger(handle)).toHaveCount(0) + } + }) + + test('Props: resizable / floating-resize* - 7 方向 resize 应生效,并保证 end 与最后一次 progress 对齐', async ({ + layout, + }) => { + await layout.setMode('floating') + + for (const resizeCase of resizeCases) { + await layout.resetFloating() + const before = await layout.getBox(layout.surface) + + await layout.resizeSurface(resizeCase.handle, resizeCase.deltaX, resizeCase.deltaY) + + const harness = await layout.readHarness() + const logs = harness.logs.floatingResize.filter((entry) => entry.handle === resizeCase.handle) + const progressLogs = logs.filter((entry) => entry.phase === 'progress') + const endLog = logs[logs.length - 1] + const lastProgressLog = progressLogs[progressLogs.length - 1] + const after = await layout.getBox(layout.surface) + + expect(harness.metrics.floatingResizeStartByHandle[resizeCase.handle]).toBeGreaterThan(0) + expect(harness.metrics.floatingResizeEndByHandle[resizeCase.handle]).toBeGreaterThan(0) + expect(logs[0]?.phase).toBe('start') + expect(logs[0]?.placement).toBe('top-left') + expect(endLog?.phase).toBe('end') + expect(progressLogs.length).toBeGreaterThan(0) + expect(endLog?.placement).toBe('top-left') + expect(endLog?.offsetX).toBe(lastProgressLog?.offsetX) + expect(endLog?.offsetY).toBe(lastProgressLog?.offsetY) + expect(endLog?.width).toBe(lastProgressLog?.width) + expect(endLog?.height).toBe(lastProgressLog?.height) + + resizeCase.assert(before, after) + } + }) + + test('viewport clamp - 超界拖拽后应被限制在视口内', async ({ layout }) => { + await layout.setMode('floating') + await layout.dragSurface(-2000, -2000) + + await expect + .poll(async () => { + const box = await layout.surface.boundingBox() + return box ? box.x >= 0 && box.y >= 0 : false + }) + .toBe(true) + + await layout.dragSurface(4000, 4000) + + await expect + .poll(async () => { + const box = await layout.surface.boundingBox() + const viewport = layout.page.viewportSize() + + if (!box || !viewport) { + return false + } + + return box.x + box.width <= viewport.width && box.y + box.height <= viewport.height + }) + .toBe(true) + }) + + test('Controlled props: floatingState - 受控父级不回写时应只发事件,不自改位置和尺寸', async ({ layout }) => { + await layout.showFloatingFixtures() + + const surface = layout.blockedFloatingSurface + const before = await layout.getBox(surface) + + await layout.dragSurface(140, 40, surface) + await expect + .poll(async () => Number(await layout.page.getByTestId('blocked-floating-updates').textContent())) + .toBeGreaterThan(0) + await expect + .poll(async () => layout.page.getByTestId('blocked-floating-last-placement').textContent()) + .toBe('top-left') + await expect + .poll(async () => Number(await layout.page.getByTestId('blocked-floating-last-offset-x').textContent())) + .toBeGreaterThan(64) + await expect + .poll(async () => Number(await layout.page.getByTestId('blocked-floating-last-offset-y').textContent())) + .toBeGreaterThan(96) + + const afterDrag = await layout.getBox(surface) + expect(Math.abs(afterDrag.x - before.x)).toBeLessThan(2) + expect(Math.abs(afterDrag.y - before.y)).toBeLessThan(2) + + await layout.resizeSurface('se', 120, 120, surface) + await expect + .poll(async () => Number(await layout.page.getByTestId('blocked-floating-last-width').textContent())) + .toBeGreaterThan(420) + await expect + .poll(async () => layout.page.getByTestId('blocked-floating-last-placement').textContent()) + .toBe('top-left') + + const afterResize = await layout.getBox(surface) + expect(Math.abs(afterResize.width - before.width)).toBeLessThan(2) + expect(Math.abs(afterResize.height - before.height)).toBeLessThan(2) + }) + + test('Default props: defaultFloatingState - 非受控 floating 应按 placement 初始化,并在交互后更新内部 rect', async ({ + layout, + }) => { + await layout.showFloatingFixtures() + + const surface = layout.uncontrolledFloatingSurface + const before = await layout.getBox(surface) + + await layout.expectSurfaceMode('floating', surface) + expect(before.width).toBeGreaterThanOrEqual(416) + expect(before.width).toBeLessThanOrEqual(424) + expect(before.height).toBeGreaterThanOrEqual(296) + expect(before.height).toBeLessThanOrEqual(304) + + await layout.dragSurface(-120, 60, surface) + await expect + .poll(async () => Number(await layout.page.getByTestId('uncontrolled-floating-updates').textContent())) + .toBeGreaterThan(0) + await expect + .poll(async () => layout.page.getByTestId('uncontrolled-floating-last-placement').textContent()) + .toBe('top-right') + await expect + .poll(async () => Number(await layout.page.getByTestId('uncontrolled-floating-last-offset-x').textContent())) + .toBeGreaterThan(24) + await expect + .poll(async () => Number(await layout.page.getByTestId('uncontrolled-floating-last-offset-y').textContent())) + .toBeGreaterThan(32) + + const after = await layout.getBox(surface) + expect(after.x).toBeLessThan(before.x - 40) + expect(after.y).toBeGreaterThan(before.y + 20) + }) + + test('Default props: 初始化后更新 defaultFloatingState 不应重新同步', async ({ layout }) => { + await layout.showFloatingFixtures() + + const surface = layout.uncontrolledFloatingSurface + const before = await layout.getBox(surface) + + await layout.page.getByTestId('uncontrolled-default-floating-update-btn').click() + + const after = await layout.getBox(surface) + expect(Math.abs(after.x - before.x)).toBeLessThan(2) + expect(Math.abs(after.y - before.y)).toBeLessThan(2) + expect(Math.abs(after.width - before.width)).toBeLessThan(2) + }) + + test('Controlled 判定: floatingState = undefined 时应退回 defaultFloatingState 非受控语义', async ({ layout }) => { + await layout.showFloatingFixtures() + + const surface = layout.getFloatingSurfaceByMarker('undefined-floating') + const before = await layout.getBox(surface) + + await layout.expectSurfaceMode('floating', surface) + expect(before.width).toBeGreaterThanOrEqual(384) + expect(before.width).toBeLessThanOrEqual(392) + expect(before.height).toBeGreaterThanOrEqual(272) + expect(before.height).toBeLessThanOrEqual(280) + + await layout.dragSurface(-80, -60, surface) + + const after = await layout.getBox(surface) + expect(after.x).toBeLessThan(before.x - 20) + expect(after.y).toBeLessThan(before.y - 20) + }) + + test('floatingOptions: minWidth / maxWidth - 非受控 floating resize 应 obey clamp', async ({ layout }) => { + await layout.showFloatingFixtures() + + const surface = layout.uncontrolledFloatingSurface + + await layout.resizeSurface('w', -240, 0, surface) + await expect + .poll(async () => Number(await layout.page.getByTestId('uncontrolled-floating-last-width').textContent())) + .toBeGreaterThan(420) + + const expanded = await layout.getBox(surface) + expect(expanded.width).toBeGreaterThanOrEqual(476) + expect(expanded.width).toBeLessThanOrEqual(484) + + await layout.resizeSurface('w', 400, 0, surface) + const shrunk = await layout.getBox(surface) + expect(shrunk.width).toBeGreaterThanOrEqual(316) + expect(shrunk.width).toBeLessThanOrEqual(324) + }) +}) diff --git a/packages/test/src/layout/specs/main-scrollbar.spec.ts b/packages/test/src/layout/specs/main-scrollbar.spec.ts new file mode 100644 index 000000000..85d0215ae --- /dev/null +++ b/packages/test/src/layout/specs/main-scrollbar.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '../helpers' + +test.describe('Layout 组件测试 - Main Scrollbar', () => { + test('Props: scrollTarget - 应解析真实滚动宿主', async ({ layout }) => { + await expect(layout.scrollTarget).toBeVisible() + }) + + test('虚拟滚动条 - 长列表时应显示并随滚动同步', async ({ layout }) => { + const { scrollTarget, scrollbarThumb: thumb, scrollbar } = layout + + await scrollTarget.hover() + await expect(scrollbar).toBeVisible() + + const before = await thumb.evaluate((node) => window.getComputedStyle(node).transform) + await scrollTarget.evaluate((node) => { + node.scrollTop = 320 + node.dispatchEvent(new Event('scroll')) + }) + + await expect.poll(async () => thumb.evaluate((node) => window.getComputedStyle(node).transform)).not.toBe(before) + }) + + test('thumb 拖拽 - 应驱动 scrollTarget 滚动', async ({ layout }) => { + await layout.scrollTarget.hover() + await layout.dragBy(layout.scrollbarThumb, 0, 120) + + await expect.poll(async () => layout.scrollTarget.evaluate((node) => node.scrollTop)).toBeGreaterThan(0) + }) + + test('thumb 拖拽 - 应锁定并恢复 body 交互', async ({ layout }) => { + await layout.scrollTarget.hover() + const thumb = layout.scrollbarThumb + const box = await thumb.boundingBox() + + if (!box) { + throw new Error('Missing scrollbar thumb') + } + + const startX = box.x + box.width / 2 + const startY = box.y + box.height / 2 + + await layout.page.mouse.move(startX, startY) + await layout.page.mouse.down() + + await expect + .poll(async () => layout.page.evaluate(() => `${document.body.style.cursor}|${document.body.style.userSelect}`)) + .toBe('grabbing|none') + + await layout.page.mouse.up() + + await expect + .poll(async () => layout.page.evaluate(() => `${document.body.style.cursor}|${document.body.style.userSelect}`)) + .toBe('|') + }) + + test('内容追加后 - metrics 应同步更新', async ({ layout }) => { + const thumb = layout.scrollbarThumb + const before = await thumb.evaluate((node) => node.getBoundingClientRect().height) + + await layout.appendMessages() + await expect.poll(async () => (await layout.readHarness()).messagesCount).toBe(60) + + await expect.poll(async () => thumb.evaluate((node) => node.getBoundingClientRect().height)).toBeLessThan(before) + }) + + test('内容从不可滚动变为可滚动后 - 应自动显示代理滚动条', async ({ layout }) => { + await layout.resetMessagesToShortList() + await expect.poll(async () => (await layout.readHarness()).messagesCount).toBe(2) + await expect(layout.scrollbar).toHaveCount(0) + + await layout.appendMessages() + await expect.poll(async () => (await layout.readHarness()).messagesCount).toBe(22) + await layout.scrollTarget.hover() + + await expect(layout.scrollbar).toBeVisible() + }) + + test('滚动条下边界 - thumb 不应越出轨道', async ({ layout }) => { + const { scrollTarget, scrollbar: track, scrollbarThumb: thumb } = layout + + await scrollTarget.evaluate((node) => { + node.scrollTop = node.scrollHeight + node.dispatchEvent(new Event('scroll')) + }) + + await expect + .poll(async () => { + const trackBox = await track.boundingBox() + const thumbBox = await thumb.boundingBox() + + if (!trackBox || !thumbBox) { + return false + } + + return thumbBox.y + thumbBox.height <= trackBox.y + trackBox.height + 1 + }) + .toBe(true) + }) +}) diff --git a/packages/test/src/layout/specs/structure.spec.ts b/packages/test/src/layout/specs/structure.spec.ts new file mode 100644 index 000000000..df5e30ea3 --- /dev/null +++ b/packages/test/src/layout/specs/structure.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '../helpers' + +test.describe('Layout 组件测试 - 结构', () => { + test('Slots: header / footer / left-aside / right-aside - 应正确渲染', async ({ layout }) => { + await expect(layout.page.getByTestId('layout-header-slot')).toBeVisible() + await expect(layout.page.getByTestId('layout-footer-slot')).toBeVisible() + await expect(layout.page.getByTestId('left-aside-slot')).toBeVisible() + await expect(layout.page.getByTestId('right-aside-slot')).toBeHidden() + + await layout.toggleAside('right') + await expect(layout.page.getByTestId('right-aside-slot')).toBeVisible() + }) + + test('Stable structure - 应能稳定识别关键区域和侧栏状态', async ({ layout }) => { + await expect(layout.surface).toBeVisible() + await expect(layout.main).toBeVisible() + await expect(layout.getAside('left')).toHaveClass(/tr-layout__aside--left/) + await expect(layout.getAside('right')).toHaveClass(/tr-layout__aside--right/) + await layout.expectAsideState('left', 'open') + await layout.expectAsideState('right', 'closed') + }) + + test('Layout.AsideToggle slot - 应能拿到 isOpen', async ({ layout }) => { + await expect(layout.page.getByTestId('left-toggle-slot')).toHaveText('left-open') + await layout.page.getByTestId('left-aside-toggle').click() + await expect(layout.page.getByTestId('left-toggle-slot')).toHaveText('left-close') + }) + + test('Fallthrough attrs: class / id / data-* - 应始终落在 surface', async ({ layout }) => { + const { surface } = layout + + await expect(surface).toHaveAttribute('id', 'layout-demo-surface') + await expect(surface).toHaveAttribute('data-surface-marker', 'layout-demo-surface') + await expect(surface).toHaveClass(/layout-demo__layout--surface-marker/) + + await layout.setMode('floating') + + await expect(surface).toHaveAttribute('id', 'layout-demo-surface') + await expect(surface).toHaveAttribute('data-surface-marker', 'layout-demo-surface') + await expect(surface).toHaveClass(/layout-demo__layout--surface-marker/) + }) + + test('Conditional slot declarations: omitted header / left-aside - should not keep shell or resize trigger', async ({ + layout, + }) => { + await layout.omitConditionalSlots() + + await expect(layout.page.locator('.tr-layout__header')).toHaveCount(0) + await expect(layout.getAside('left')).toHaveCount(0) + await expect(layout.getResizeTrigger('left')).toHaveCount(0) + }) +})