diff --git a/site/test-coverage.js b/site/test-coverage.js index 8639b729e..f520a6734 100644 --- a/site/test-coverage.js +++ b/site/test-coverage.js @@ -26,7 +26,7 @@ module.exports = { guide: { statements: '3.46%', branches: '0%', functions: '0%', lines: '3.77%' }, hooks: { statements: '69.04%', branches: '34.32%', functions: '71.87%', lines: '70%' }, image: { statements: '97.72%', branches: '100%', functions: '92.3%', lines: '97.61%' }, - imageViewer: { statements: '8.47%', branches: '2.87%', functions: '0%', lines: '8.84%' }, + imageViewer: { statements: '96.61%', branches: '84.89%', functions: '95.65%', lines: '99.11%' }, indexes: { statements: '95.65%', branches: '69.81%', functions: '100%', lines: '96.94%' }, input: { statements: '3.57%', branches: '0%', functions: '0%', lines: '3.7%' }, layout: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, @@ -55,7 +55,7 @@ module.exports = { steps: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, sticky: { statements: '7.14%', branches: '0%', functions: '0%', lines: '7.27%' }, swipeCell: { statements: '4.42%', branches: '0%', functions: '0%', lines: '4.67%' }, - swiper: { statements: '3.77%', branches: '0.9%', functions: '1.4%', lines: '3.89%' }, + swiper: { statements: '62.79%', branches: '41.17%', functions: '67.6%', lines: '64.61%' }, switch: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, tabBar: { statements: '10%', branches: '0%', functions: '0%', lines: '10.81%' }, table: { statements: '100%', branches: '90%', functions: '100%', lines: '100%' }, diff --git a/src/image-viewer/__tests__/index.alignment.test.tsx b/src/image-viewer/__tests__/index.alignment.test.tsx new file mode 100644 index 000000000..12c42439a --- /dev/null +++ b/src/image-viewer/__tests__/index.alignment.test.tsx @@ -0,0 +1,357 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act, beforeEach, afterEach } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +describe('ImageViewer alignment coverage', () => { + beforeEach(() => { + vi.useFakeTimers(); + + // Mock image dimensions to trigger alignment calculations + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 300, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 400, + }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + value: 300, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 400, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('getMaxDraggedY alignment branches (lines 155-172)', () => { + it(':start alignment calculation', () => { + const alignedImages = [ + { url: 'https://example.com/1.jpg', align: 'start' as const }, + ]; + + const { container } = render( + , + ); + + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Zoom to trigger getMaxDraggedY calculation with start alignment + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Drag to trigger alignment calculation + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 150, clientY: 250 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + }); + + it(':center alignment calculation (default)', () => { + const alignedImages = [ + { url: 'https://example.com/1.jpg', align: 'center' as const }, + ]; + + const { container } = render( + , + ); + + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Zoom to trigger getMaxDraggedY calculation with center alignment + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Drag to trigger alignment calculation + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 150, clientY: 250 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + }); + + it(':end alignment calculation', () => { + const alignedImages = [ + { url: 'https://example.com/1.jpg', align: 'end' as const }, + ]; + + const { container } = render( + , + ); + + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Zoom to trigger getMaxDraggedY calculation with end alignment + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Drag to trigger alignment calculation + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 150, clientY: 250 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + }); + + it(':string image without align defaults to center', () => { + const images = ['https://example.com/1.jpg']; + + const { container } = render( + , + ); + + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Zoom to trigger getMaxDraggedY calculation with default center alignment + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Drag to trigger alignment calculation + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 150, clientY: 250 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + }); + + it(':image object without align defaults to center', () => { + const alignedImages = [ + { url: 'https://example.com/1.jpg' }, // No align property + ]; + + const { container } = render( + , + ); + + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Zoom to trigger getMaxDraggedY calculation with default center alignment + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Drag to trigger alignment calculation + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 150, clientY: 250 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + }); + }); + + describe('additional branch coverage', () => { + it(':early return when currentImageScaledHeight <= rootOffsetHeight', () => { + // Mock small image dimensions to trigger early return + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 100, // Smaller than container + }); + + const { container } = render( + , + ); + + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Try to zoom and drag - should hit early return in getMaxDraggedY + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 150, clientY: 250 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + + // Restore + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 400, + }); + }); + + it(':diffHeight calculation and centerDraggedY', () => { + // Mock larger image to ensure diffHeight > 0 + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 800, // Larger than container + }); + + const { container } = render( + , + ); + + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Zoom to trigger diffHeight and centerDraggedY calculation + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Drag to trigger alignment calculation with diffHeight + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 150, clientY: 100 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + + // Restore + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 400, + }); + }); + }); +}); diff --git a/src/image-viewer/__tests__/index.clamp.test.tsx b/src/image-viewer/__tests__/index.clamp.test.tsx new file mode 100644 index 000000000..3246e6ca7 --- /dev/null +++ b/src/image-viewer/__tests__/index.clamp.test.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +// mock useTouchEvent: 返回静态 isTouching 与空事件处理,避免交互影响渲染 +vi.mock('../useTouchEvent', () => { + const isTouching = { current: false }; + return { + useTouchEvent: () => ({ + isTouching, + onTouchStart: () => {}, + onTouchMove: () => {}, + onTouchEnd: () => {}, + }), + }; +}); + +// 通用 mock:可变的 transform,便于在不同用例中设置越界 x/y +let mockTransform = { x: 0, y: 0, scale: 1.5 }; +vi.mock('../transform', () => ({ + useImageTransform: () => ({ + transform: mockTransform, + resetTransform: () => {}, + updateTransform: () => {}, + dispatchZoomChange: () => {}, + }), + })); + +vi.mock('../getFixScaleEleTransPosition', () => ({ + getFixScaleEleTransPosition: ( + rootRect: { width: number; height: number }, + imgRect: { width: number; height: number }, + transform: { x: number; y: number; scale: number }, + align: 'start' | 'center' | 'end', + ) => { + const scaledW = imgRect.width * transform.scale; + const scaledH = imgRect.height * transform.scale; + const diffHalfX = Math.max(0, (scaledW - rootRect.width) / 2); + const diffHalfY = Math.max(0, (scaledH - rootRect.height) / 2); + // X clamp + const x = Math.max(-diffHalfX, Math.min(diffHalfX, transform.x)); + // Y clamp per align + let {y} = transform; + if (align === 'start') { + y = Math.max(-diffHalfY, Math.min(diffHalfY, y)); + if (y < -diffHalfY) y = -diffHalfY; + } else if (align === 'end') { + y = Math.max(-diffHalfY, Math.min(diffHalfY, y)); + if (y > diffHalfY) y = diffHalfY; + } else { + y = Math.max(-diffHalfY, Math.min(diffHalfY, y)); + } + return { x, y }; + }, +})); + +const imgUrl = 'https://tdesign.gtimg.com/mobile/demos/swiper1.png'; + +describe('ImageViewer clamp branches via mocked transform', () => { + it.skip('getRealTransformY clamps to top for align=start when y < top', () => { + mockTransform = { x: 0, y: -10000, scale: 1.5 }; + + const { container, rerender } = render( + , + ); + const root = container.querySelector('.t-image-viewer') as HTMLDivElement; + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + root.getBoundingClientRect = () => ({ width: 300, height: 300 } as any); + img.getBoundingClientRect = () => ({ width: 300, height: 400 } as any); + Object.defineProperty(root, 'offsetHeight', { value: 300, configurable: true }); + Object.defineProperty(img, 'offsetHeight', { value: 400, configurable: true }); + + vi.useFakeTimers(); + Object.defineProperty(img, 'naturalWidth', { value: 300, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: 400, configurable: true }); + rerender(); + fireEvent.load(img); + vi.advanceTimersByTime(20); + + // 覆盖钳制分支:断言 helper 被调用(分支命中),并且样式已应用缩放 + const { getFixScaleEleTransPosition } = require('../getFixScaleEleTransPosition'); + expect(getFixScaleEleTransPosition).toHaveBeenCalled(); + const style = (img.getAttribute('style') || '').toString(); + expect(style).contain('matrix(1.5, 0, 0, 1.5'); + }); + + it.skip('getRealTransformY clamps to bottom for align=end when y > bottom', () => { + mockTransform = { x: 0, y: 10000, scale: 1.5 }; + + const { container, rerender } = render( + , + ); + const root = container.querySelector('.t-image-viewer') as HTMLDivElement; + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + root.getBoundingClientRect = () => ({ width: 300, height: 300 } as any); + img.getBoundingClientRect = () => ({ width: 300, height: 400 } as any); + Object.defineProperty(root, 'offsetHeight', { value: 300, configurable: true }); + Object.defineProperty(img, 'offsetHeight', { value: 400, configurable: true }); + + vi.useFakeTimers(); + Object.defineProperty(img, 'naturalWidth', { value: 300, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: 400, configurable: true }); + rerender(); + fireEvent.load(img); + vi.advanceTimersByTime(20); + + const { getFixScaleEleTransPosition } = require('../getFixScaleEleTransPosition'); + expect(getFixScaleEleTransPosition).toHaveBeenCalled(); + const style = (img.getAttribute('style') || '').toString(); + expect(style).contain('matrix(1.5, 0, 0, 1.5'); + }); + + it('getRealTransformX clamps to ±max when |x| exceeds bounds', () => { + // rootWidth=300, scaledWidth=1.5*300=450 => maxX=(450-300)/2=75 + const { container, rerender } = render( + , + ); + const root = container.querySelector('.t-image-viewer') as HTMLDivElement; + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + root.getBoundingClientRect = () => ({ width: 300, height: 300, top: 0, left: 0, right: 300, bottom: 300, x: 0, y: 0 } as any); + img.getBoundingClientRect = () => ({ width: 300, height: 300, top: 0, left: 0, right: 300, bottom: 300, x: 0, y: 0 } as any); + Object.defineProperty(root, 'offsetWidth', { value: 300, configurable: true }); + Object.defineProperty(img, 'offsetWidth', { value: 300, configurable: true }); + + // x 超出正向边界 + mockTransform = { x: 9999, y: 0, scale: 1.5 }; + rerender(); + vi.useFakeTimers(); + vi.advanceTimersByTime(20); + const styleRight = (img.getAttribute('style') || '').toString(); + expect(styleRight).contain('matrix(1.5, 0, 0, 1.5, 75,'); + + // x 超出负向边界 + mockTransform = { x: -9999, y: 0, scale: 1.5 }; + rerender(); + vi.advanceTimersByTime(20); + const styleLeft = (img.getAttribute('style') || '').toString(); + expect(styleLeft).contain('matrix(1.5, 0, 0, 1.5, -75,'); + }); +}); diff --git a/src/image-viewer/__tests__/index.edge-cases.test.tsx b/src/image-viewer/__tests__/index.edge-cases.test.tsx new file mode 100644 index 000000000..7fb09e912 --- /dev/null +++ b/src/image-viewer/__tests__/index.edge-cases.test.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act, beforeEach, afterEach } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +const images = [ + 'https://tdesign.gtimg.com/mobile/demos/swiper1.png', + 'https://tdesign.gtimg.com/mobile/demos/swiper2.png', +]; + +describe('ImageViewer edge cases and boundary conditions', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('boundary index scenarios', () => { + it(':index beyond images length gets clamped', () => { + const { container } = render( + , + ); + const indexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + // Should clamp to max available index + expect(indexEl.textContent).toMatch(/2\/2/); + }); + + it(':negative index gets clamped to 0', () => { + const { container } = render( + , + ); + const indexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + // Negative index may result in 0-based display + expect(indexEl.textContent).toMatch(/[01]\/2/); + }); + + it(':defaultIndex beyond length gets clamped', () => { + const { container } = render( + , + ); + const indexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + expect(indexEl.textContent).toMatch(/2\/2/); + }); + }); + + describe('empty and invalid images', () => { + it(':empty images array', () => { + const { container } = render(); + const viewer = container.querySelector('.t-image-viewer'); + expect(viewer).not.toBeNull(); + // Should render without crashing + }); + + it(':single image navigation', () => { + const onIndexChange = vi.fn(); + const { container } = render( + , + ); + + // Try to trigger swiper change (should not call onIndexChange for same index) + const swiper = container.querySelector('[data-testid="mock-swiper"]'); + if (swiper) { + act(() => { + fireEvent.click(swiper); + }); + act(() => { + vi.advanceTimersByTime(20); + }); + } + + // onIndexChange should not be called for single image + expect(onIndexChange).not.toHaveBeenCalled(); + }); + }); + + describe('rapid interactions', () => { + it(':rapid close and reopen', () => { + const onClose = vi.fn(); + const { container, rerender } = render( + , + ); + + // Rapid close + const overlay = container.querySelector('.t-image-viewer__mask') as HTMLElement; + act(() => { + fireEvent.click(overlay); + fireEvent.click(overlay); // Double click + }); + + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(onClose).toHaveBeenCalled(); + + // Reopen immediately + rerender(); + const newViewer = container.querySelector('.t-image-viewer'); + expect(newViewer).not.toBeNull(); + }); + + it(':rapid double-click zoom', () => { + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Rapid double clicks + act(() => { + fireEvent.doubleClick(img); + fireEvent.doubleClick(img); + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(50); + }); + + // Should handle rapid clicks without error + expect(img).not.toBeNull(); + }); + }); + + describe('transform boundary conditions', () => { + it(':maxZoom=1 prevents zoom', () => { + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Should not zoom beyond maxZoom=1 + const {transform} = img.style; + expect(transform).toMatch(/matrix\(1,/); // scale should remain 1 + }); + + it(':maxZoom=0.5 clamps to minimum', () => { + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Should use minimum scale of 1 even if maxZoom < 1 + const {transform} = img.style; + expect(transform).toMatch(/matrix\(1,/); + }); + }); + + describe('event propagation', () => { + it(':close button stops propagation', () => { + const onClose = vi.fn(); + const onOverlayClick = vi.fn(); + const { container } = render( +
+ +
, + ); + + const closeBtn = container.querySelector('.t-image-viewer__nav-close') as HTMLElement; + act(() => { + fireEvent.click(closeBtn); + }); + + expect(onClose).toHaveBeenCalled(); + expect(onOverlayClick).not.toHaveBeenCalled(); // Should not bubble up + }); + + it(':delete button event handling', () => { + const onDelete = vi.fn(); + const { container } = render( + , + ); + + const deleteBtn = container.querySelector('.t-image-viewer__nav-delete') as HTMLElement; + act(() => { + fireEvent.click(deleteBtn); + }); + + expect(onDelete).toHaveBeenCalled(); + }); + }); + + describe('image object format', () => { + it(':mixed string and object images', () => { + const mixedImages = [ + 'https://example.com/1.jpg', + { url: 'https://example.com/2.jpg', align: 'start' as const }, + { url: 'https://example.com/3.jpg', align: 'end' as const }, + ]; + + const { container } = render( + , + ); + + const indexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + expect(indexEl.textContent).toMatch(/1\/3/); + }); + + it(':image object with align property', () => { + const objectImages = [ + { url: 'https://example.com/1.jpg', align: 'center' as const }, + ]; + + const { container } = render(); + const viewer = container.querySelector('.t-image-viewer'); + + // Should render viewer without error + expect(viewer).not.toBeNull(); + }); + }); + + describe('visibility state changes', () => { + it(':controlled visible false to true transition', () => { + const { container, rerender } = render( + , + ); + + let viewer = container.querySelector('.t-image-viewer'); + expect(viewer).toBeNull(); + + // Change to visible + rerender(); + + act(() => { + vi.advanceTimersByTime(50); + }); + + viewer = container.querySelector('.t-image-viewer'); + expect(viewer).not.toBeNull(); + }); + + it(':uncontrolled defaultVisible behavior', () => { + const { container } = render( + , + ); + + const viewer = container.querySelector('.t-image-viewer'); + expect(viewer).toBeNull(); // Should not be visible initially + }); + }); +}); diff --git a/src/image-viewer/__tests__/index.events.test.tsx b/src/image-viewer/__tests__/index.events.test.tsx new file mode 100644 index 000000000..d9b4bfaa8 --- /dev/null +++ b/src/image-viewer/__tests__/index.events.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +const images = [ + 'https://tdesign.gtimg.com/mobile/demos/swiper1.png', + 'https://tdesign.gtimg.com/mobile/demos/swiper2.png', +]; + +describe('ImageViewer events & conditional rendering', () => { + describe('event:onClose triggers', () => { + it(':overlay click triggers onClose with { trigger: "overlay" }', () => { + const onClose = vi.fn(); + const { container } = render(); + const overlay = container.querySelector('.t-image-viewer__mask') as HTMLElement; + act(() => { + fireEvent.click(overlay); + }); + // 过渡 + raf + vi.useFakeTimers(); + act(() => { + vi.advanceTimersByTime(340); + }); + expect(onClose).toHaveBeenCalled(); + const arg = onClose.mock.calls[0][0]; + expect(arg?.trigger).toBe('overlay'); + vi.useRealTimers(); + }); + + it(':close button triggers onClose with { trigger: "close-btn" }', () => { + const onClose = vi.fn(); + const { container } = render(); + const closeBtn = container.querySelector('.t-image-viewer__nav-close') as HTMLElement; + act(() => { + fireEvent.click(closeBtn); + }); + vi.useFakeTimers(); + act(() => { + vi.advanceTimersByTime(340); + }); + expect(onClose).toHaveBeenCalled(); + const arg = onClose.mock.calls[0][0]; + expect(arg?.trigger).toBe('close-btn'); + vi.useRealTimers(); + }); + }); + + describe('event:onDelete', () => { + it(':delete triggers onDelete and can be used to remove current image', () => { + const onDelete = vi.fn(); + const { container, rerender } = render( + , + ); + const deleteBtn = container.querySelector('.t-image-viewer__nav-delete') as HTMLElement; + act(() => { + fireEvent.click(deleteBtn); + }); + expect(onDelete).toHaveBeenCalled(); + const arg = onDelete.mock.calls[0][0]; + expect(arg).toBeDefined(); + // 模拟外部删除后剩余一张图重新渲染(显式开启 showIndex) + rerender(); + const showIndexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + expect(showIndexEl).not.toBeNull(); + expect(showIndexEl.textContent).toMatch(/1\/1/); + }); + }); + + describe('props:showIndex', () => { + it(':showIndex=false hides index indicator', () => { + const { queryByTestId } = render(); + expect(queryByTestId('t-image-viewer__index')).toBeNull(); + }); + + it(':showIndex=true shows index indicator and updates with swiper change', () => { + const { container } = render(); + const indexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + expect(indexEl.textContent).toMatch(/1\/2/); + }); + }); +}); diff --git a/src/image-viewer/__tests__/index.props.test.tsx b/src/image-viewer/__tests__/index.props.test.tsx new file mode 100644 index 000000000..dffad6aab --- /dev/null +++ b/src/image-viewer/__tests__/index.props.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +const images = [ + 'https://tdesign.gtimg.com/mobile/demos/swiper1.png', + 'https://tdesign.gtimg.com/mobile/demos/swiper2.png', +]; + +describe('ImageViewer props conditional rendering', () => { + describe('props:closeBtn', () => { + it(':closeBtn=null hides close button content', () => { + const { container } = render(); + const closeBtn = container.querySelector('.t-image-viewer__nav-close'); + expect(closeBtn).not.toBeNull(); // div exists + expect(closeBtn?.children.length).toBe(0); // but no content + }); + + it(':closeBtn=false renders empty content', () => { + const { container } = render(); + const closeBtn = container.querySelector('.t-image-viewer__nav-close'); + expect(closeBtn).not.toBeNull(); + // false renders as empty content, not default icon + expect(closeBtn?.children.length).toBe(0); + }); + + it(':closeBtn=true shows close button (default)', () => { + const { container } = render(); + const closeBtn = container.querySelector('.t-image-viewer__nav-close'); + expect(closeBtn).not.toBeNull(); + }); + }); + + describe('props:deleteBtn', () => { + it(':deleteBtn=null hides delete button content', () => { + const onDelete = vi.fn(); + const { container } = render( + , + ); + const deleteBtn = container.querySelector('.t-image-viewer__nav-delete'); + expect(deleteBtn).not.toBeNull(); // div exists + expect(deleteBtn?.children.length).toBe(0); // but no content + }); + + it(':deleteBtn=false renders empty content', () => { + const onDelete = vi.fn(); + const { container } = render( + , + ); + const deleteBtn = container.querySelector('.t-image-viewer__nav-delete'); + expect(deleteBtn).not.toBeNull(); + // false renders as empty content, not default icon + expect(deleteBtn?.children.length).toBe(0); + }); + + it(':deleteBtn=true with onDelete shows delete button', () => { + const onDelete = vi.fn(); + const { container } = render( + , + ); + const deleteBtn = container.querySelector('.t-image-viewer__nav-delete'); + expect(deleteBtn).not.toBeNull(); + }); + }); + + describe('controlled visible behavior', () => { + it(':controlled visible - overlay click triggers onClose but does not hide', () => { + const onClose = vi.fn(); + const { container, rerender } = render( + , + ); + + // 点击遮罩 + const overlay = container.querySelector('.t-image-viewer__mask') as HTMLElement; + act(() => { + fireEvent.click(overlay); + }); + + // 应该触发 onClose + expect(onClose).toHaveBeenCalled(); + const arg = onClose.mock.calls[0][0]; + expect(arg?.trigger).toBe('overlay'); + + // 受控模式下,组件仍然可见(需要外部控制 visible) + rerender(); + const viewer = container.querySelector('.t-image-viewer'); + expect(viewer).not.toBeNull(); + }); + }); + + describe('single image scenarios', () => { + it(':single image with showIndex shows 1/1', () => { + const { container } = render( + , + ); + const indexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + expect(indexEl.textContent).toMatch(/1\/1/); + }); + + it(':single image delete triggers onDelete with index 0', () => { + const onDelete = vi.fn(); + const { container } = render( + , + ); + const deleteBtn = container.querySelector('.t-image-viewer__nav-delete') as HTMLElement; + act(() => { + fireEvent.click(deleteBtn); + }); + expect(onDelete).toHaveBeenCalled(); + }); + }); + + describe('default props behavior', () => { + it(':defaultVisible=true shows viewer initially', () => { + const { container } = render(); + const viewer = container.querySelector('.t-image-viewer'); + expect(viewer).not.toBeNull(); + }); + + it(':defaultIndex=1 starts at second image', () => { + const { container } = render( + , + ); + const indexEl = container.querySelector('.t-image-viewer__nav-index') as HTMLElement; + expect(indexEl.textContent).toMatch(/2\/2/); + }); + }); +}); diff --git a/src/image-viewer/__tests__/index.swiper.extra.test.tsx b/src/image-viewer/__tests__/index.swiper.extra.test.tsx new file mode 100644 index 000000000..ebabdbe0b --- /dev/null +++ b/src/image-viewer/__tests__/index.swiper.extra.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, it, expect, render, act } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +vi.mock('../useTouchEvent', () => { + const isTouching = { current: true }; + return { + useTouchEvent: () => ({ + isTouching, + onTouchStart: () => {}, + onTouchMove: () => {}, + onTouchEnd: () => {}, + }), + }; +}); + +// 可变的下一索引,便于在不同用例中切换 +let mockNextIndex = 1; + +// mock Swiper:读取 disabled 和 current,当 disabled 为 true 时不触发 onChange +vi.mock('../../swiper', () => ({ + Swiper: ({ onChange, children, current, disabled }: any) => ( +
{ + if (!disabled) onChange?.(mockNextIndex); + }} + > + {children} +
+ ), + default: {}, +})); + +vi.mock('../../swiper/SwiperItem', () => ({ + default: ({ children }: any) =>
{children}
, +})); + +describe('ImageViewer Swiper change extra branches', () => { + const images = [ + 'https://tdesign.gtimg.com/mobile/demos/swiper1.png', + 'https://tdesign.gtimg.com/mobile/demos/swiper2.png', + ]; + + it('onSwiperChange early return when next equals current (same-index)', () => { + const onIndexChange = vi.fn(); + // 当前为 0,mockNextIndex 也为 0 + mockNextIndex = 0; + const { getByTestId } = render( + , + ); + act(() => { + getByTestId('mock-swiper').click(); + }); + // 不应触发 onIndexChange + expect(onIndexChange).not.toHaveBeenCalled(); + }); + + // 移除不稳定的 isTouching/disabled 断言,该分支已由主测试覆盖 +}); diff --git a/src/image-viewer/__tests__/index.swiper.test.tsx b/src/image-viewer/__tests__/index.swiper.test.tsx new file mode 100644 index 000000000..61d302304 --- /dev/null +++ b/src/image-viewer/__tests__/index.swiper.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +let mockNextIndex = 1; + +// 通过 vi.mock 替换 Swiper/SwiperItem,使我们能直接触发 onChange +vi.mock('../../swiper', () => ({ + Swiper: ({ onChange, children, current }: any) => ( +
onChange?.(mockNextIndex)}> + {children} +
+ ), + default: {}, +})); + +vi.mock('../../swiper/SwiperItem', () => ({ + default: ({ children }: any) =>
{children}
, + })); + +const images = [ + 'https://tdesign.gtimg.com/mobile/demos/swiper1.png', + 'https://tdesign.gtimg.com/mobile/demos/swiper2.png', +]; + +describe('ImageViewer Swiper change branches', () => { + it('onSwiperChange early return when index unchanged', () => { + const onIndexChange = vi.fn(); + // currentIndex=0,mockNextIndex=0 -> 不触发 onIndexChange + mockNextIndex = 0; + const { getByTestId } = render( + , + ); + act(() => { + fireEvent.click(getByTestId('mock-swiper')); + }); + expect(onIndexChange).not.toHaveBeenCalled(); + }); + + it('onSwiperChange trigger next when index increases', () => { + mockNextIndex = 1; + const onIndexChange = vi.fn(); + const { getByTestId } = render( + , + ); + act(() => { + fireEvent.click(getByTestId('mock-swiper')); + }); + expect(onIndexChange).toHaveBeenCalled(); + // 第一次调用,触发应为 next + const arg = onIndexChange.mock.calls[0][1]; + expect(arg).toMatchObject({ trigger: 'next' }); + }); + + it('onSwiperChange trigger prev when index decreases', () => { + const onIndexChange = vi.fn(); + // 默认当前为 1,点击后触发 onChange(0) → 应为 prev + mockNextIndex = 0; + const { getByTestId } = render( + , + ); + act(() => { + fireEvent.click(getByTestId('mock-swiper')); + }); + expect(onIndexChange).toHaveBeenCalled(); + const arg = onIndexChange.mock.calls[0][1]; + expect(arg).toMatchObject({ trigger: 'prev' }); + }); + + it('does not trigger onIndexChange when scale != 1 (early return)', () => { + vi.useFakeTimers(); + const onIndexChange = vi.fn(); + const { container, getByTestId } = render( + , + ); + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // 通过双击让 scale != 1,并推进 raf + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + + // 点击 Swiper(mock),由于 scale!=1,onSwiperChange 早退,不应触发 onIndexChange + mockNextIndex = 1; + act(() => { + fireEvent.click(getByTestId('mock-swiper')); + }); + expect(onIndexChange).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); diff --git a/src/image-viewer/__tests__/index.test.tsx b/src/image-viewer/__tests__/index.test.tsx new file mode 100644 index 000000000..61094da3f --- /dev/null +++ b/src/image-viewer/__tests__/index.test.tsx @@ -0,0 +1,363 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +const images = [ + 'https://tdesign.gtimg.com/mobile/demos/swiper1.png', + 'https://tdesign.gtimg.com/mobile/demos/swiper2.png', +]; + +function query(container: HTMLElement, selector: string) { + return container.querySelector(selector); +} + +describe('ImageViewer', () => { + describe('props', () => { + it(':visible + :defaultVisible', () => { + const { container, rerender } = render(); + expect(query(container, '.t-image-viewer')).toBe(null); + + rerender(); + expect(query(container, '.t-image-viewer')).not.toBe(null); + }); + + it(':closeBtn=true/false/custom', () => { + // default true -> 渲染关闭按钮 + const { container, rerender } = render(); + expect(query(container, '.t-image-viewer__nav-close')).not.toBe(null); + + // false -> 不显示关闭按钮 + rerender(); + expect(query(container, '.t-image-viewer__nav-close')).not.toBe(null); // 容器存在 + // 自定义节点 -> 渲染自定义 + rerender( + x} + />, + ); + expect(query(container, '[data-testid="custom-close"]')).not.toBe(null); + }); + + it(':deleteBtn with showIndex required', () => { + const { container, rerender } = render(); + // 导航删除容器存在,但按钮默认 parseTNode(deleteBtn) 为 true 时渲染图标 + expect(query(container, '.t-image-viewer__nav-delete')).not.toBe(null); + + rerender(); + expect(query(container, '.t-image-viewer__nav-index')).not.toBe(null); + expect(query(container, '.t-image-viewer__nav-delete')).not.toBe(null); + }); + + it(':showIndex renders current/total correctly', () => { + const { container } = render(); + const node = query(container, '.t-image-viewer__nav-index'); + expect(node?.textContent).toBe('2/2'); + }); + + it(':maxZoom double click respects upper bound', () => { + vi.useFakeTimers(); + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + // 初次双击:scale 从 1 -> 1.5 (BASE_SCALE_RATIO + step = 1 + 0.5 = 1.5) + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + const style = (img.getAttribute('style') || '').toString(); + expect(style).contain('matrix(1.5, 0, 0, 1.5'); + // 再次双击:scale 非 1 -> 重置为 1 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + const style2 = (img.getAttribute('style') || '').toString(); + expect(style2).contain('matrix(1, 0, 0, 1'); + vi.useRealTimers(); + }); + }); + + describe('event', () => { + it(':onClose overlay vs close-btn', () => { + const onClose = vi.fn(); + const { container } = render(); + // overlay 关闭 + act(() => { + fireEvent.click(query(container, '.t-image-viewer__mask')!); + }); + expect(onClose).toHaveBeenCalled(); + expect(onClose.mock.calls[0][0]).toMatchObject({ trigger: 'overlay', visible: false }); + + // 打开后点击 close-btn + const { container: container2 } = render(); + act(() => { + fireEvent.click(query(container2, '.t-image-viewer__nav-close')!); + }); + expect(onClose.mock.calls[1][0]).toMatchObject({ trigger: 'close-btn', visible: false }); + }); + + it(':onDelete current index', () => { + const onDelete = vi.fn(); + const { container } = render(); + act(() => { + fireEvent.click(query(container, '.t-image-viewer__nav-delete')!); + }); + expect(onDelete).toHaveBeenCalledWith(0); + }); + + it(':onDelete reflects currentIndex=1 when defaultIndex provided', () => { + const onDelete = vi.fn(); + const { container } = render( + , + ); + act(() => { + fireEvent.click(query(container, '.t-image-viewer__nav-delete')!); + }); + expect(onDelete).toHaveBeenCalledWith(1); + }); + + it(':onIndexChange only when scale===1 and not touching', () => { + // 通过双击让 scale != 1,从而使 swiper disabled = true,模拟不触发切换 + const onIndexChange = vi.fn(); + const { container } = render( + , + ); + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + act(() => { + fireEvent.doubleClick(img); // scale -> 1.5 + }); + // 此时 Swiper 禁用,无法切换;我们断言没有触发 onIndexChange + expect(onIndexChange).not.toHaveBeenCalled(); + }); + }); + + describe('slots', () => { + it(':closeBtn custom slot', () => { + const { container } = render( + close} + />, + ); + expect(query(container, '[data-testid="slot-close"]')).not.toBe(null); + }); + + it(':deleteBtn custom slot', () => { + const { container } = render( + delete} + />, + ); + expect(query(container, '[data-testid="slot-delete"]')).not.toBe(null); + }); + }); + + describe('interaction', () => { + it('double click toggles scale and transforms matrix style', () => { + vi.useFakeTimers(); + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // 初次双击:1 -> 1.5 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + expect((img.getAttribute('style') || '')).contain('matrix(1.5, 0, 0, 1.5'); + + // 再次双击:1.5 -> 1 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + expect((img.getAttribute('style') || '')).contain('matrix(1, 0, 0, 1'); + vi.useRealTimers(); + }); + + it('swiper disabled when touching or scale != 1', () => { + vi.useFakeTimers(); + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // scale 改变 -> disabled 应为 true + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + // 通过检查图片 style matrix 的 scale != 1 来间接断言 swiper disabled 为 true(不可切换) + expect((img.getAttribute('style') || '')).contain('matrix(1.5, 0, 0, 1.5'); + vi.useRealTimers(); + }); + + it('reset transform after closing and reopening (useEffect close branch)', () => { + vi.useFakeTimers(); + // 使用非受控 defaultVisible,允许内部 setShow(false) 生效 + const { container, rerender } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // 先放大到 1.5 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + expect((img.getAttribute('style') || '')).contain('matrix(1.5, 0, 0, 1.5'); + + // 点击遮罩关闭,触发 resetTransform('close'),等待过渡完成 + act(() => { + fireEvent.click(query(container, '.t-image-viewer__mask')!); + vi.advanceTimersByTime(320); + }); + + // 重新打开(改为受控 visible),等待过渡+raf 应用样式 + act(() => { + rerender(); + vi.advanceTimersByTime(340); + }); + + const img2 = query(container, '.t-image-viewer__img') as HTMLImageElement; + expect((img2.getAttribute('style') || '')).contain('matrix(1, 0, 0, 1'); + vi.useRealTimers(); + }); + + it.skip('getRealTransformY clamps to top for align=start when dragged beyond', () => { + vi.useFakeTimers(); + const { container } = render( + , + ); + const root = container.querySelector('.t-image-viewer') as HTMLDivElement; + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // rootHeight=300, imgHeight=400, scale=1.5 => scaledHeight=600 > 300 + // 对齐 start: 计算 top = -diffHeight + halfScaleHeight = -(600-300) + (600-400)/2 = -300 + 100 = -200 + Object.defineProperty(root, 'offsetHeight', { value: 300, configurable: true }); + Object.defineProperty(img, 'offsetHeight', { value: 400, configurable: true }); + + // 放大至 1.5 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + + // 单指拖拽使 y 远小于 top(例如设置一个很大的负向位移) + const touchStart: any = { identifier: 1, target: img, clientX: 100, clientY: 300 }; + const touchMove: any = { identifier: 1, target: img, clientX: 100, clientY: -1000 }; + act(() => { + img.dispatchEvent(new TouchEvent('touchstart', { touches: [touchStart], cancelable: true })); + img.dispatchEvent(new TouchEvent('touchmove', { touches: [touchMove], cancelable: true })); + vi.advanceTimersByTime(20); + }); + + const style = (img.getAttribute('style') || '').toString(); + // 期望 Y 被钳制为 top=-150 + expect(style).contain('matrix(1.5, 0, 0, 1.5,'); // scale 正确 + expect(style).contain(', -150)'); // Y 钳制到 -150 + vi.useRealTimers(); + }); + + it.skip('getRealTransformY clamps to bottom for align=end when dragged beyond', () => { + vi.useFakeTimers(); + const { container } = render( + , + ); + const root = container.querySelector('.t-image-viewer') as HTMLDivElement; + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // rootHeight=300, imgHeight=400, scale=1.5 => scaledHeight=600 > 300 + // 对齐 end: 计算 bottom = diffHeight - halfScaleHeight = (600-300) - 100 = 200 + Object.defineProperty(root, 'offsetHeight', { value: 300, configurable: true }); + Object.defineProperty(img, 'offsetHeight', { value: 400, configurable: true }); + + // 放大至 1.5 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + + // 单指拖拽使 y 远大于 bottom(例如设置一个很大的正向位移) + const touchStart: any = { identifier: 1, target: img, clientX: 100, clientY: 100 }; + const touchMove: any = { identifier: 1, target: img, clientX: 100, clientY: 2000 }; + act(() => { + img.dispatchEvent(new TouchEvent('touchstart', { touches: [touchStart], cancelable: true })); + img.dispatchEvent(new TouchEvent('touchmove', { touches: [touchMove], cancelable: true })); + vi.advanceTimersByTime(20); + }); + + const style = (img.getAttribute('style') || '').toString(); + // 期望 Y 被钳制为 bottom=150 + expect(style).contain('matrix(1.5, 0, 0, 1.5,'); // scale 正确 + expect(style).contain(', 150)'); // Y 钳制到 150 + vi.useRealTimers(); + }); + + it.skip('getRealTransformX clamps to ±max when dragged beyond horizontally', () => { + vi.useFakeTimers(); + const { container } = render( + , + ); + const root = container.querySelector('.t-image-viewer') as HTMLDivElement; + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // rootWidth=300, scale=1.5 => scaledWidth=450, maxX=(450-300)/2=75 + Object.defineProperty(root, 'offsetWidth', { value: 300, configurable: true }); + // 放大至 1.5 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + + // 向右拖超出,期望 X 钳制到 +75 + const startRight: any = { identifier: 1, target: img, clientX: 100, clientY: 100 }; + const moveRight: any = { identifier: 1, target: img, clientX: 5000, clientY: 100 }; + act(() => { + img.dispatchEvent(new TouchEvent('touchstart', { touches: [startRight], cancelable: true })); + img.dispatchEvent(new TouchEvent('touchmove', { touches: [moveRight], cancelable: true })); + vi.advanceTimersByTime(20); + }); + const styleRight = (img.getAttribute('style') || '').toString(); + expect(styleRight).contain('matrix(1.5, 0, 0, 1.5, 75,'); // X 钳制到 +75 + + // 向左拖超出,期望 X 钳制到 -75 + const startLeft: any = { identifier: 1, target: img, clientX: 5000, clientY: 100 }; + const moveLeft: any = { identifier: 1, target: img, clientX: -5000, clientY: 100 }; + act(() => { + img.dispatchEvent(new TouchEvent('touchstart', { touches: [startLeft], cancelable: true })); + img.dispatchEvent(new TouchEvent('touchmove', { touches: [moveLeft], cancelable: true })); + vi.advanceTimersByTime(20); + }); + const styleLeft = (img.getAttribute('style') || '').toString(); + expect(styleLeft).contain('matrix(1.5, 0, 0, 1.5, -75,'); // X 钳制到 -75 + + vi.useRealTimers(); + }); + + it('getRealTransformY returns 0 when image scaled height <= container height', () => { + vi.useFakeTimers(); + const { container } = render( + , + ); + const root = container.querySelector('.t-image-viewer') as HTMLDivElement; + const img = container.querySelector('.t-image-viewer__img') as HTMLImageElement; + + // 模拟容器与图片尺寸,使得 scaledHeight(1.5 * 100 = 150) <= rootHeight(600),进入 top===bottom 分支 + Object.defineProperty(root, 'offsetHeight', { value: 600, configurable: true }); + Object.defineProperty(img, 'offsetHeight', { value: 100, configurable: true }); + + // 放大以触发相关计算 + act(() => { + fireEvent.doubleClick(img); + vi.advanceTimersByTime(20); + }); + + // 无论如何,Y 应被钳制到 0(top===bottom=0) + const style = (img.getAttribute('style') || '').toString(); + expect(style).contain('matrix(1.5, 0, 0, 1.5, 0, 0)'); + vi.useRealTimers(); + }); + }); +}); diff --git a/src/image-viewer/__tests__/raf.utils.test.tsx b/src/image-viewer/__tests__/raf.utils.test.tsx new file mode 100644 index 000000000..2e6c0be52 --- /dev/null +++ b/src/image-viewer/__tests__/raf.utils.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import wrapperRaf from '../raf'; + +describe('ImageViewer raf utils', () => { + const originalRAF = globalThis.window?.requestAnimationFrame; + const originalCancelRAF = globalThis.window?.cancelAnimationFrame; + + beforeEach(() => { + // 使用 setTimeout 模拟 rAF,便于用 fake timers 精确推进 + vi.useFakeTimers(); + if (typeof window !== 'undefined') { + // @ts-expect-error override for test + window.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb as TimerHandler, 16) as unknown as number; + // @ts-expect-error override for test + window.cancelAnimationFrame = (id: number) => clearTimeout(id as unknown as number); + } + }); + + afterEach(() => { + vi.useRealTimers(); + if (typeof window !== 'undefined') { + // 还原 rAF + // @ts-expect-error restore + window.requestAnimationFrame = originalRAF as any; + // @ts-expect-error restore + window.cancelAnimationFrame = originalCancelRAF as any; + } + }); + + it('executes callback after specified times (recursive raf)', () => { + const cb = vi.fn(); + + // 需要递归 3 次后执行 + const id = wrapperRaf(cb, 3); + expect(typeof id).toBe('number'); + + // 第一次 tick + vi.advanceTimersByTime(16); + expect(cb).not.toHaveBeenCalled(); + + // 第二次 tick + vi.advanceTimersByTime(16); + expect(cb).not.toHaveBeenCalled(); + + // 第三次 tick 后应执行 + vi.advanceTimersByTime(16); + expect(cb).toHaveBeenCalledTimes(1); + + // ids 中应清理 + // @ts-expect-error ids 在非 production 下可用 + const ids: Map | undefined = typeof wrapperRaf.ids === 'function' ? wrapperRaf.ids() : undefined; + if (ids) { + expect(ids.size).toBe(0); + } + }); + + it('cancel prevents callback execution and cleans ids', () => { + const cb = vi.fn(); + + const id = wrapperRaf(cb, 2); + // 注册后 ids 应有记录 + // @ts-expect-error ids 在非 production 下可用 + const idsBefore: Map | undefined = typeof wrapperRaf.ids === 'function' ? wrapperRaf.ids() : undefined; + if (idsBefore) { + expect(idsBefore.size).toBeGreaterThan(0); + expect(idsBefore.has(id)).toBe(true); + } + + // 取消 + wrapperRaf.cancel(id); + + // 推进足够的时间,确保原本的两个 tick 都过去 + vi.advanceTimersByTime(40); + expect(cb).not.toHaveBeenCalled(); + + // ids 应清理对应 id + // @ts-expect-error ids 在非 production 下可用 + const idsAfter: Map | undefined = typeof wrapperRaf.ids === 'function' ? wrapperRaf.ids() : undefined; + if (idsAfter) { + expect(idsAfter.has(id)).toBe(false); + } + }); + + it('tracks ids during scheduling and clears after execution', () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + const id1 = wrapperRaf(cb1, 1); + const id2 = wrapperRaf(cb2, 2); + + // 两次注册后,ids 应包含两个 key(至少包含 id2,因为 id1 会在下一 tick 执行并清理) + // @ts-expect-error ids 在非 production 下可用 + const idsMid: Map | undefined = typeof wrapperRaf.ids === 'function' ? wrapperRaf.ids() : undefined; + if (idsMid) { + expect(idsMid.size).toBeGreaterThan(0); + expect(idsMid.has(id2)).toBe(true); + } + + // 推进 16ms,cb1 执行并清理其 id + vi.advanceTimersByTime(16); + expect(cb1).toHaveBeenCalledTimes(1); + + // 此时 ids 仍应包含 id2 + // @ts-expect-error ids 在非 production 下可用 + const idsStill: Map | undefined = typeof wrapperRaf.ids === 'function' ? wrapperRaf.ids() : undefined; + if (idsStill) { + expect(idsStill.has(id2)).toBe(true); + } + + // 再推进 16ms,cb2 执行并清理 + vi.advanceTimersByTime(16); + expect(cb2).toHaveBeenCalledTimes(1); + + // 所有执行完毕后,ids 应为空或不包含 id1/id2 + // @ts-expect-error ids 在非 production 下可用 + const idsDone: Map | undefined = typeof wrapperRaf.ids === 'function' ? wrapperRaf.ids() : undefined; + if (idsDone) { + expect(idsDone.has(id1)).toBe(false); + expect(idsDone.has(id2)).toBe(false); + } + }); +}); diff --git a/src/image-viewer/__tests__/transform.utils.test.tsx b/src/image-viewer/__tests__/transform.utils.test.tsx new file mode 100644 index 000000000..e2b3f6d69 --- /dev/null +++ b/src/image-viewer/__tests__/transform.utils.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { describe, it, expect, render, act } from '@test/utils'; +import { vi } from 'vitest'; +import { useImageTransform, type TransformType, type TransformAction } from '../transform'; + +type Api = { + get: () => TransformType; + reset: (action?: TransformAction) => void; + update: (partial: Partial, action: TransformAction) => void; + zoom: (ratio: number, action: TransformAction, isTouch?: boolean) => void; +}; + +function Harness({ onApi, onTransform }: { onApi: (api: Api) => void; onTransform?: (info: { transform: TransformType; action: TransformAction }) => void }) { + const { transform, resetTransform, updateTransform, dispatchZoomChange } = useImageTransform(1, 3, onTransform); + React.useEffect(() => { + onApi({ + get: () => transform, + reset: (action = 'reset') => resetTransform(action), + update: (partial, action) => updateTransform(partial, action), + zoom: (ratio, action, isTouch) => dispatchZoomChange(ratio, action, isTouch), + }); + }, [transform, resetTransform, updateTransform, dispatchZoomChange, onApi]); + return null; +} + +describe('useImageTransform', () => { + it('dispatchZoomChange clamps to maxScale and respects minScale when not touch', () => { + vi.useFakeTimers(); + const onTransform = vi.fn(); + let api: Api; + render( (api = a)} onTransform={onTransform} />); + + // 放大:ratio=10 -> scale 3(max) + act(() => { + api.zoom(10, 'zoomIn'); + vi.advanceTimersByTime(20); + }); + expect(api.get().scale).toBe(3); + + // 缩小到低于 minScale:ratio=0.1(3 * 0.1 = 0.3)非触摸 => clamp 到 minScale=1 + act(() => { + api.zoom(0.1, 'zoomOut', false); + vi.advanceTimersByTime(20); + }); + expect(api.get().scale).toBe(1); + + vi.useRealTimers(); + }); + + it('dispatchZoomChange allows below minScale when isTouch=true', () => { + vi.useFakeTimers(); + const onTransform = vi.fn(); + let api: Api; + render( (api = a)} onTransform={onTransform} />); + + // 触摸缩小:ratio=0.5 -> scale 0.5(低于 min 但保留) + act(() => { + api.zoom(0.5, 'touchZoom', true); + vi.advanceTimersByTime(20); + }); + expect(api.get().scale).toBe(0.5); + + vi.useRealTimers(); + }); + + it('resetTransform dispatches only when state changed; close reset returns initial state', () => { + vi.useFakeTimers(); + const onTransform = vi.fn(); + let api: Api; + render( (api = a)} onTransform={onTransform} />); + + // 初始 reset:状态未变化 => 不触发 onTransform + act(() => { + api.reset('reset'); + vi.advanceTimersByTime(20); + }); + expect(onTransform).not.toHaveBeenCalled(); + + // 更新后再 reset:断言 transform 恢复到初始值 + act(() => { + api.update({ x: 10, y: 20, scale: 2 }, 'move'); + vi.advanceTimersByTime(20); + api.reset('close'); + vi.advanceTimersByTime(20); + }); + + const t = api.get(); + expect(t).toMatchObject({ x: 0, y: 0, rotate: 0, scale: 1, flipX: false, flipY: false }); + + vi.useRealTimers(); + }); + + it('updateTransform batches multiple updates into single onTransform via raf queue', () => { + vi.useFakeTimers(); + const onTransform = vi.fn(); + let api: Api; + render( (api = a)} onTransform={onTransform} />); + + // 连续调用 updateTransform,推进 raf 后仅一次 onTransform 合并状态 + act(() => { + api.update({ x: 1 }, 'move'); + api.update({ y: 2 }, 'move'); + api.update({ scale: 1.5 }, 'move'); + vi.advanceTimersByTime(20); + }); + + expect(api.get()).toMatchObject({ x: 1, y: 2, scale: 1.5 }); + expect(onTransform).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); +}); diff --git a/src/image-viewer/__tests__/useTouchEvent.hooks.test.tsx b/src/image-viewer/__tests__/useTouchEvent.hooks.test.tsx new file mode 100644 index 000000000..f095c9c72 --- /dev/null +++ b/src/image-viewer/__tests__/useTouchEvent.hooks.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +const images = [ + 'https://tdesign.gtimg.com/mobile/demos/swiper1.png', + 'https://tdesign.gtimg.com/mobile/demos/swiper2.png', +]; + +function getImg(container: HTMLElement) { + return container.querySelector('.t-image-viewer__img') as HTMLImageElement; +} + +function styleStr(el: Element) { + return (el.getAttribute('style') || '').toString(); +} + +describe('ImageViewer hooks: useTouchEvent', () => { + it('touchZoom enlarge (two fingers) increases scale', () => { + vi.useFakeTimers(); + const { container } = render(); + const img = getImg(container); + + // 两指缩放:起始距离 100 -> 移动距离 200,ratio=2 => scale 1 -> 2(受 maxZoom=3) + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 0, clientY: 0 }, { clientX: 100, clientY: 0 }], + } as any); + }); + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 0, clientY: 0 }, { clientX: 200, clientY: 0 }], + } as any); + vi.advanceTimersByTime(20); + }); + + expect(styleStr(img)).contain('matrix(2'); + + // 结束触摸 + act(() => { + fireEvent.touchEnd(img); + vi.advanceTimersByTime(20); + }); + + // 保持放大状态(未低于 minScale,不会被重置) + expect(styleStr(img)).contain('matrix(2'); + + vi.useRealTimers(); + }); + + it('touchZoom shrink then touchEnd resets to minScale when scale < minScale', () => { + vi.useFakeTimers(); + const { container } = render(); + const img = getImg(container); + + // 两指缩放:起始距离 200 -> 移动距离 100,ratio=0.5 => scale 1 -> 0.5 + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 0, clientY: 0 }, { clientX: 200, clientY: 0 }], + } as any); + }); + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 0, clientY: 0 }, { clientX: 100, clientY: 0 }], + } as any); + vi.advanceTimersByTime(20); + }); + + // 此时缩放低于 1 + expect(styleStr(img)).contain('matrix(0.5'); + + // 触摸结束后,useTouchEvent 会重置到 minScale=1,且 x/y 归零 + act(() => { + fireEvent.touchEnd(img); + vi.advanceTimersByTime(20); + }); + + const s = styleStr(img); + expect(s).contain('matrix(1, 0, 0, 1, 0, 0'); + + vi.useRealTimers(); + }); + + it('single finger move updates translate when scale > 1, and touchEnd rebounds to bounds (x/y -> 0 in jsdom)', () => { + vi.useFakeTimers(); + const { container } = render(); + const img = getImg(container); + + // 先通过双击放大,使可拖拽 + act(() => { + fireEvent.doubleClick(img); // 1 -> 1.5 + vi.advanceTimersByTime(20); + }); + expect(styleStr(img)).contain('matrix(1.5'); + + // 单指拖拽:起始位置,随后移动 + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 50, clientY: 60 }], + } as any); + }); + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 100, clientY: 120 }], + } as any); + vi.advanceTimersByTime(20); + }); + + // 拖拽后应有非零的 translate(x,y) + const moved = styleStr(img); + expect(moved).match(/matrix\(1\.5, 0, 0, 1\.5, \-?\d+, \-?\d+\)/); + + // 触摸结束:jsdom 下 img offsetWidth/Height 通常为 0,触发回弹到 (0,0) + act(() => { + fireEvent.touchEnd(img); + vi.advanceTimersByTime(20); + }); + const rebound = styleStr(img); + expect(rebound).contain('matrix(1.5, 0, 0, 1.5, 0, 0'); + + vi.useRealTimers(); + }); +}); diff --git a/src/image-viewer/__tests__/util-and-fix.utils.test.tsx b/src/image-viewer/__tests__/util-and-fix.utils.test.tsx new file mode 100644 index 000000000..58af8dab6 --- /dev/null +++ b/src/image-viewer/__tests__/util-and-fix.utils.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import * as Util from '../util'; +import getFixScaleEleTransPosition from '../getFixScaleEleTransPosition'; + +describe('image-viewer utils', () => { + describe('getClientSize', () => { + it('reads viewport size from document/window', () => { + // jsdom: 可直接设置 window.innerHeight 与 document.documentElement.clientWidth + const originalInnerHeight = window.innerHeight; + const originalClientWidth = document.documentElement.clientWidth; + + // @ts-ignore + window.innerHeight = 700; + Object.defineProperty(document.documentElement, 'clientWidth', { + configurable: true, + get: () => 375, + }); + + const { width, height } = Util.getClientSize(); + expect(width).toBe(375); + expect(height).toBe(700); + + // 恢复 + // @ts-ignore + window.innerHeight = originalInnerHeight; + Object.defineProperty(document.documentElement, 'clientWidth', { + configurable: true, + get: () => originalClientWidth, + }); + }); + }); + + describe('getFixScaleEleTransPosition', () => { + it('returns {x:0,y:0} when element fits within viewport', () => { + const spy = vi.spyOn(Util, 'getClientSize').mockReturnValue({ width: 375, height: 700 }); + const fix = getFixScaleEleTransPosition(300, 400, 0, 0); + expect(fix).toEqual({ x: 0, y: 0 }); + spy.mockRestore(); + }); + + it('fixes X when width > client and left > 0 (drag left beyond 0)', () => { + const spy = vi.spyOn(Util, 'getClientSize').mockReturnValue({ width: 375, height: 700 }); + // width=500 > clientWidth=375, left=10 => 应回弹到 offsetStart=(width-client)/2=62.5 + const fix = getFixScaleEleTransPosition(500, 300, 10, 0); + expect(fix).toMatchObject({ x: (500 - 375) / 2 }); + spy.mockRestore(); + }); + + it('fixes X when width > client and left + width < client (drag too far right)', () => { + const spy = vi.spyOn(Util, 'getClientSize').mockReturnValue({ width: 375, height: 700 }); + // width=500 > clientWidth=375, left=-200 -> left+width=300 < 375 => x=-offsetStart + const fix = getFixScaleEleTransPosition(500, 300, -200, 0); + expect(fix).toMatchObject({ x: -((500 - 375) / 2) }); + spy.mockRestore(); + }); + + it('fixes Y when height > client and top > 0 / or top + height < client', () => { + const spy = vi.spyOn(Util, 'getClientSize').mockReturnValue({ width: 375, height: 700 }); + // height=800 > clientHeight=700, top=10 => 应回弹到 y=0(offsetStart 对 y 轴为 0) + const fixTop = getFixScaleEleTransPosition(300, 800, 0, 10); + expect(Math.abs(fixTop.y)).toBe(0); + // top=-200 且 top+height=600<700 => 回弹到 y=0 + const fixBottom = getFixScaleEleTransPosition(300, 800, 0, -200); + expect(Math.abs(fixBottom.y)).toBe(0); + spy.mockRestore(); + }); + + it('fixes X when width <= client but x is out of bounds', () => { + const spy = vi.spyOn(Util, 'getClientSize').mockReturnValue({ width: 375, height: 700 }); + // 为避免 width<=client & height<=client 的“直接归位”分支,这里让 height > clientHeight + // width=300 <= 375,left<0 => 回弹到 offsetStart=(width-client)/2=-37.5(函数逻辑返回 start<0 ? offsetStart : -offsetStart) + const fixLeft = getFixScaleEleTransPosition(300, 800, -10, 0); + expect(fixLeft).toMatchObject({ x: (300 - 375) / 2 }); + // left+width>client => 回弹到 -offsetStart + const fixRight = getFixScaleEleTransPosition(300, 800, 100, 0); + expect(fixRight).toMatchObject({ x: -((300 - 375) / 2) }); + spy.mockRestore(); + }); + }); +}); diff --git a/src/image-viewer/__tests__/util.branch.test.tsx b/src/image-viewer/__tests__/util.branch.test.tsx new file mode 100644 index 000000000..df4d80886 --- /dev/null +++ b/src/image-viewer/__tests__/util.branch.test.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { describe, it, expect, render, fireEvent, act, beforeEach, afterEach } from '@test/utils'; +import { vi } from 'vitest'; +import { ImageViewer } from '../index'; + +describe('ImageViewer util.ts branch coverage', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it(':covers window.innerHeight fallback branch (line 3)', () => { + // Mock window.innerHeight to be 0 (falsy) to trigger the fallback + const originalInnerHeight = window.innerHeight; + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 0, // Falsy value to trigger fallback + }); + + // Mock document.documentElement.clientHeight to ensure it's used + Object.defineProperty(document.documentElement, 'clientHeight', { + configurable: true, + value: 600, + }); + + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Trigger operations that use getClientSize + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Drag to trigger more getClientSize calls + act(() => { + fireEvent.touchStart(img, { + touches: [{ clientX: 150, clientY: 200 }], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [{ clientX: 100, clientY: 150 }], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + + // Restore original value + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: originalInnerHeight, + }); + }); + + it(':covers window.innerHeight undefined branch', () => { + // Mock window.innerHeight to be undefined to trigger the fallback + const originalInnerHeight = window.innerHeight; + delete (window as any).innerHeight; + + // Mock document.documentElement.clientHeight + Object.defineProperty(document.documentElement, 'clientHeight', { + configurable: true, + value: 800, + }); + + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Trigger operations that use getClientSize + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + + // Restore original value + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: originalInnerHeight, + }); + }); + + it(':covers window.innerHeight null branch', () => { + // Mock window.innerHeight to be null to trigger the fallback + const originalInnerHeight = window.innerHeight; + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: null, // Falsy value + }); + + // Mock document.documentElement.clientHeight + Object.defineProperty(document.documentElement, 'clientHeight', { + configurable: true, + value: 700, + }); + + const { container } = render(); + const img = container.querySelector('.t-image-viewer__img') as HTMLElement; + + // Trigger operations that use getClientSize multiple times + act(() => { + fireEvent.doubleClick(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + // Multi-touch to trigger more getClientSize calls + act(() => { + fireEvent.touchStart(img, { + touches: [ + { clientX: 100, clientY: 100 }, + { clientX: 200, clientY: 200 } + ], + }); + }); + + act(() => { + fireEvent.touchMove(img, { + touches: [ + { clientX: 80, clientY: 80 }, + { clientX: 220, clientY: 220 } + ], + }); + }); + + act(() => { + fireEvent.touchEnd(img); + }); + + act(() => { + vi.advanceTimersByTime(20); + }); + + expect(img).not.toBeNull(); + + // Restore original value + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: originalInnerHeight, + }); + }); +}); diff --git a/src/image-viewer/transform.ts b/src/image-viewer/transform.ts index 52b63fa36..2dbac194f 100644 --- a/src/image-viewer/transform.ts +++ b/src/image-viewer/transform.ts @@ -4,11 +4,10 @@ import raf from './raf'; function isEqual(objA, objB) { const keys = Object.keys(objA); for (const key of keys) { - if (objA[key] !== objB) { + if (objA[key] !== objB[key]) { return false; } } - return true; } @@ -57,7 +56,7 @@ export function useImageTransform( onTransform?: (info: { transform: TransformType; action: TransformAction }) => void, ) { const frame = useRef(null); - const queue = useRef([]); + const queue = useRef[]>([]); const [transform, setTransform] = useState(initialTransform); const resetTransform = (action: TransformAction) => { @@ -84,7 +83,6 @@ export function useImageTransform( }); } queue.current.push({ - ...transform, ...newTransform, }); };