From cc4c20e77b6988576ce9b862dcc9369823617ad8 Mon Sep 17 00:00:00 2001
From: cat-shark <1716967236@qq.com>
Date: Sun, 13 Sep 2026 19:06:38 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A6=96=E9=A1=B5=E6=BB=9A=E5=8A=A8?=
=?UTF-8?q?=E6=9D=A1=E6=94=B9=E4=B8=BA=E6=81=92=E9=80=9F=E5=B9=B6=E8=A1=A5?=
=?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E6=8B=96=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/__tests__/components/Welcome.spec.ts | 234 +++++++++++++++++++++++
src/__tests__/utils/autoScroll.spec.ts | 117 ++++++++++++
src/components/Welcome.vue | 159 ++++++++++++---
src/utils/autoScroll.ts | 102 ++++++++++
4 files changed, 587 insertions(+), 25 deletions(-)
create mode 100644 src/__tests__/components/Welcome.spec.ts
create mode 100644 src/__tests__/utils/autoScroll.spec.ts
create mode 100644 src/utils/autoScroll.ts
diff --git a/src/__tests__/components/Welcome.spec.ts b/src/__tests__/components/Welcome.spec.ts
new file mode 100644
index 0000000..8634d00
--- /dev/null
+++ b/src/__tests__/components/Welcome.spec.ts
@@ -0,0 +1,234 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import ElementPlus from 'element-plus'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import Welcome from '@/components/Welcome.vue'
+import { getReviewFeed } from '@/api/review'
+import { BASE_SPEED_PX_PER_SEC, NARROW_SPEED_FACTOR } from '@/utils/autoScroll'
+
+vi.mock('@/api/review', () => ({
+ getReviewFeed: vi.fn(),
+ getTaskReview: vi.fn(),
+}))
+
+const makeFragment = (id: number) => ({
+ id,
+ sessionNum: `SESSION_${id}`,
+ taskName: `任务${id}`,
+ taskNum: `T${id}`,
+ sourceType: 'FRAGMENT',
+ content: `残片内容${id}`,
+})
+
+const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: { template: '
' } },
+ { path: '/welcome', component: { template: '' } },
+ { path: '/study', component: { template: '' } },
+ { path: '/review', component: { template: '' } },
+ ],
+})
+
+/** jsdom 不实现 Web Animations API,用可控桩替代 */
+interface AnimStub {
+ keyframes: unknown
+ options: { duration: number; iterations: number; easing: string }
+ currentTime: number
+ paused: boolean
+ cancel: ReturnType
+ pause: ReturnType
+ play: ReturnType
+}
+
+let animations: AnimStub[] = []
+let animateSpy: ReturnType
+
+/**
+ * jsdom 不做布局,用宽度桩模拟渲染结果:
+ * scrollWidth = 单份内容宽 × 当前已渲染份数(由 DOM 里的 chip 数量推得),
+ * clientWidth = 轨道宽度。
+ */
+const stubLayout = (perCopyWidth: number, trackWidth: number) => {
+ Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
+ configurable: true,
+ get(this: HTMLElement) {
+ if (!this.classList?.contains('review-scroll-content')) return 0
+ const renderedChips = this.querySelectorAll('.review-chip').length
+ return perCopyWidth * (renderedChips / SOURCE_CHIP_COUNT)
+ },
+ })
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
+ configurable: true,
+ get(this: HTMLElement) {
+ return this.classList?.contains('review-scroll-track') ? trackWidth : 0
+ },
+ })
+}
+
+/** mock 数据里的残留条数,用于把“份数”换算回“条数” */
+const SOURCE_CHIP_COUNT = 2
+/** 从渲染结果反推当前份数 */
+const renderedRepeatCount = (wrapper: { findAll: (s: string) => unknown[] }) =>
+ wrapper.findAll('.review-chip').length / SOURCE_CHIP_COUNT
+
+const mountWelcome = async () => {
+ const wrapper = mount(Welcome, { global: { plugins: [router, ElementPlus] } })
+ await flushPromises()
+ return wrapper
+}
+
+beforeEach(() => {
+ animations = []
+ animateSpy = vi.fn((keyframes: unknown, options: AnimStub['options']) => {
+ const anim: AnimStub = {
+ keyframes,
+ options,
+ currentTime: 0,
+ paused: false,
+ cancel: vi.fn(),
+ pause: vi.fn(function (this: AnimStub) { this.paused = true }),
+ play: vi.fn(function (this: AnimStub) { this.paused = false }),
+ }
+ animations.push(anim)
+ return anim as unknown as Animation
+ })
+ Element.prototype.animate = animateSpy as unknown as Element['animate']
+ vi.mocked(getReviewFeed).mockResolvedValue({ code: 200, data: [makeFragment(1), makeFragment(2)] })
+})
+
+afterEach(() => {
+ // @ts-expect-error 清理原型上的自定义布局桩
+ delete HTMLElement.prototype.scrollWidth
+ // @ts-expect-error 清理原型上的自定义布局桩
+ delete HTMLElement.prototype.clientWidth
+})
+
+describe('Welcome 首页滚动条', () => {
+ it('内容超过一屏时按基准速度建立动画,时长由恒速换算', async () => {
+ stubLayout(4000, 1000) // 单份 4000px,60px/s → 66666ms
+ await mountWelcome()
+
+ expect(animateSpy).toHaveBeenCalledTimes(1)
+ const options = animations[0].options
+ expect(options.iterations).toBe(Infinity)
+ expect(options.easing).toBe('linear')
+ expect(options.duration).toBeCloseTo((4000 / BASE_SPEED_PX_PER_SEC) * 1000)
+ })
+
+ it('速度与内容量无关:内容翻倍只让时长翻倍', async () => {
+ stubLayout(4000, 1000)
+ await mountWelcome()
+ const few = animations[0].options.duration
+
+ animations = []
+ animateSpy.mockClear()
+ stubLayout(40000, 1000)
+ await mountWelcome()
+ const many = animations[0].options.duration
+
+ expect(many / few).toBeCloseTo(10)
+ })
+
+ it('内容不足一屏时仍然滚动,且按单份宽度换算时长', async () => {
+ stubLayout(300, 1000) // 单份 300px,远小于轨道
+ await mountWelcome()
+
+ expect(animateSpy).toHaveBeenCalled()
+ expect(animations[animations.length - 1].options.duration)
+ .toBeCloseTo((300 / BASE_SPEED_PX_PER_SEC) * 1000)
+ })
+
+ it('内容比轨道窄时补足份数后重建动画,避免循环露白', async () => {
+ stubLayout(300, 1000) // 单份 300px、轨道 1000px → ceil(1000/300)+1 = 5 份
+ const wrapper = await mountWelcome()
+
+ expect(renderedRepeatCount(wrapper)).toBe(5)
+ expect(animateSpy).toHaveBeenCalledTimes(1)
+ // 位移按份数换算:100% / 5 = 20%
+ expect(animations[0].keyframes).toEqual([
+ { transform: 'translateX(0)' },
+ { transform: 'translateX(-20%)' },
+ ])
+ })
+
+ it('内容足够宽时保持最少两份,不会反复重建', async () => {
+ stubLayout(4000, 1000)
+ const wrapper = await mountWelcome()
+
+ expect(renderedRepeatCount(wrapper)).toBe(2)
+ expect(animateSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('窄屏(手机)按系数降速', async () => {
+ stubLayout(4000, 375)
+ await mountWelcome()
+
+ const speed = 4000 / (animations[0].options.duration / 1000)
+ expect(speed).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
+ })
+
+ it('滚轮按速度换算手动位移,并夹在一轮时长内', async () => {
+ stubLayout(4000, 1000)
+ const wrapper = await mountWelcome()
+ const track = wrapper.find('.review-scroll-track')
+
+ await track.trigger('wheel', { deltaY: BASE_SPEED_PX_PER_SEC, deltaMode: 0 })
+ // 滚动 60px = 1 秒动画时间
+ expect(animations[0].currentTime).toBeCloseTo(1000)
+
+ await track.trigger('wheel', { deltaY: -100000, deltaMode: 0 })
+ expect(animations[0].currentTime).toBe(0)
+ })
+
+ it('手指左右拖动共用同一套换算,拖动后恢复播放', async () => {
+ stubLayout(4000, 1000)
+ const wrapper = await mountWelcome()
+ const track = wrapper.find('.review-scroll-track')
+
+ await track.trigger('touchstart', { touches: [{ clientX: 300 }] })
+ expect(animations[0].paused).toBe(true)
+
+ await track.trigger('touchmove', { touches: [{ clientX: 360 }] })
+ expect(animations[0].currentTime).toBeCloseTo(1000)
+
+ await track.trigger('touchend')
+ expect(animations[0].paused).toBe(false)
+ })
+
+ it('拖动后松开不会误触打开回忆卡片', async () => {
+ stubLayout(4000, 1000)
+ const wrapper = await mountWelcome()
+ const track = wrapper.find('.review-scroll-track')
+
+ await track.trigger('touchstart', { touches: [{ clientX: 300 }] })
+ await track.trigger('touchmove', { touches: [{ clientX: 400 }] })
+ await wrapper.find('.review-chip').trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('.recall-question').exists()).toBe(false)
+ })
+
+ it('点击残片(未拖动)会打开回忆卡片', async () => {
+ stubLayout(4000, 1000)
+ const wrapper = await mountWelcome()
+
+ await wrapper.find('.review-chip').trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('.recall-question').exists()).toBe(true)
+ expect(wrapper.find('.recall-snippet').text()).toContain('残片内容1')
+ })
+
+ it('卸载时取消动画并移除窗口监听', async () => {
+ const removeSpy = vi.spyOn(window, 'removeEventListener')
+ stubLayout(4000, 1000)
+ const wrapper = await mountWelcome()
+
+ wrapper.unmount()
+
+ expect(animations[0].cancel).toHaveBeenCalled()
+ expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
+ removeSpy.mockRestore()
+ })
+})
diff --git a/src/__tests__/utils/autoScroll.spec.ts b/src/__tests__/utils/autoScroll.spec.ts
new file mode 100644
index 0000000..43e5fe6
--- /dev/null
+++ b/src/__tests__/utils/autoScroll.spec.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect } from 'vitest'
+import {
+ BASE_SPEED_PX_PER_SEC,
+ MAX_REPEAT_COUNT,
+ MAX_SPEED_PX_PER_SEC,
+ MIN_REPEAT_COUNT,
+ MIN_SPEED_PX_PER_SEC,
+ NARROW_SPEED_FACTOR,
+ clampAutoScrollTime,
+ getRepeatCount,
+ getScrollSpeedPxPerSec,
+ measureAutoScroll,
+ pxToTimeOffset,
+} from '@/utils/autoScroll'
+
+const DESKTOP = 1440
+
+describe('getScrollSpeedPxPerSec', () => {
+ it('桌面端使用基准速度', () => {
+ expect(getScrollSpeedPxPerSec(DESKTOP)).toBe(BASE_SPEED_PX_PER_SEC)
+ })
+
+ it('正好 768px 属于手机端,769px 回到桌面速度', () => {
+ expect(getScrollSpeedPxPerSec(768)).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
+ expect(getScrollSpeedPxPerSec(769)).toBe(BASE_SPEED_PX_PER_SEC)
+ })
+
+ it('手机端是桌面速度的固定倍数', () => {
+ const narrow = getScrollSpeedPxPerSec(375)
+ expect(narrow).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
+ expect(narrow).toBeLessThan(getScrollSpeedPxPerSec(DESKTOP))
+ })
+
+ it('桌面速度始终夹在上下限内', () => {
+ ;[0, 769, 1024, 1440, 2560].forEach(width => {
+ const speed = getScrollSpeedPxPerSec(width)
+ expect(speed).toBeGreaterThanOrEqual(MIN_SPEED_PX_PER_SEC)
+ expect(speed).toBeLessThanOrEqual(MAX_SPEED_PX_PER_SEC)
+ })
+ })
+})
+
+describe('measureAutoScroll', () => {
+ it('一轮距离为单份内容宽度,时长由恒速换算', () => {
+ // 单份 2000px;60px/s → 33.3s
+ const metrics = measureAutoScroll(2000, DESKTOP)
+ expect(metrics).not.toBeNull()
+ expect(metrics!.loopWidth).toBe(2000)
+ expect(metrics!.speed).toBe(BASE_SPEED_PX_PER_SEC)
+ expect(metrics!.duration).toBeCloseTo((2000 / BASE_SPEED_PX_PER_SEC) * 1000)
+ })
+
+ it('速度恒定:内容量翻倍只改变时长,不改变速度', () => {
+ const few = measureAutoScroll(2000, DESKTOP)!
+ const many = measureAutoScroll(20000, DESKTOP)!
+ expect(few.speed).toBe(many.speed)
+ expect(many.duration / few.duration).toBeCloseTo(10)
+ })
+
+ it('内容为空或宽度异常时返回 null', () => {
+ expect(measureAutoScroll(0, DESKTOP)).toBeNull()
+ expect(measureAutoScroll(-100, DESKTOP)).toBeNull()
+ expect(measureAutoScroll(Number.NaN, DESKTOP)).toBeNull()
+ })
+})
+
+describe('getRepeatCount', () => {
+ it('内容再多也用最少两份', () => {
+ expect(getRepeatCount(5000, 1000)).toBe(MIN_REPEAT_COUNT)
+ })
+
+ it('内容比轨道窄时补足到能铺满并预留衔接份', () => {
+ // 单份 300px、轨道 1000px:ceil(1000/300)+1 = 5
+ expect(getRepeatCount(300, 1000)).toBe(5)
+ // 刚好铺满也要多一份用于衔接
+ expect(getRepeatCount(1000, 1000)).toBe(2)
+ })
+
+ it('份数不超过上限', () => {
+ expect(getRepeatCount(1, 100000)).toBe(MAX_REPEAT_COUNT)
+ })
+
+ it('宽度非法时回退到最少份数', () => {
+ expect(getRepeatCount(0, 1000)).toBe(MIN_REPEAT_COUNT)
+ expect(getRepeatCount(Number.NaN, 1000)).toBe(MIN_REPEAT_COUNT)
+ expect(getRepeatCount(300, 0)).toBe(MIN_REPEAT_COUNT)
+ })
+
+ it('measureAutoScroll 会带上份数', () => {
+ expect(measureAutoScroll(300, 1000)!.repeatCount).toBe(5)
+ })
+})
+
+describe('pxToTimeOffset', () => {
+ it('按速度把位移换算成时间', () => {
+ expect(pxToTimeOffset(BASE_SPEED_PX_PER_SEC, BASE_SPEED_PX_PER_SEC)).toBe(1000)
+ expect(pxToTimeOffset(30, 60)).toBe(500)
+ })
+
+ it('速度为 0 或参数非法时返回 0', () => {
+ expect(pxToTimeOffset(100, 0)).toBe(0)
+ expect(pxToTimeOffset(Number.NaN, 60)).toBe(0)
+ })
+})
+
+describe('clampAutoScrollTime', () => {
+ it('夹在 0 与一轮时长之间', () => {
+ expect(clampAutoScrollTime(-500, 1000)).toBe(0)
+ expect(clampAutoScrollTime(1500, 1000)).toBe(1000)
+ expect(clampAutoScrollTime(400, 1000)).toBe(400)
+ })
+
+ it('参数非法时归零', () => {
+ expect(clampAutoScrollTime(Number.NaN, 1000)).toBe(0)
+ expect(clampAutoScrollTime(100, 0)).toBe(0)
+ })
+})
diff --git a/src/components/Welcome.vue b/src/components/Welcome.vue
index d47a803..f2e4946 100644
--- a/src/components/Welcome.vue
+++ b/src/components/Welcome.vue
@@ -1,12 +1,21 @@
@@ -181,10 +283,15 @@ onBeforeUnmount(() => {