diff --git a/src/components/explain/ExplainGuideStopCatalog.vue b/src/components/explain/ExplainGuideStopCatalog.vue new file mode 100644 index 0000000..16a7998 --- /dev/null +++ b/src/components/explain/ExplainGuideStopCatalog.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/tests/e2e/explain-hall-page.spec.ts b/tests/e2e/explain-hall-page.spec.ts index 5b1d822..b2e9700 100644 --- a/tests/e2e/explain-hall-page.spec.ts +++ b/tests/e2e/explain-hall-page.spec.ts @@ -5,6 +5,9 @@ const stops = Array.from({ length: 21 }, (_, index) => ({ name: `讲解对象${index + 1}`, imageStatus: index === 0 ? 'READY' : 'MISSING', coverImageUrl: index === 0 ? '/one.jpg' : undefined, + audioStatus: index === 0 ? 'READY' : 'MISSING', + hasAudio: index === 0, + hasTextRecord: index !== 0, playTargetType: 'STOP' as const, playTargetId: `target-${index + 1}` })) @@ -30,6 +33,37 @@ test('hall goes directly to paged guide objects without outline requests', async await page.getByText('恐龙厅', { exact: true }).click() await expect(page).toHaveURL(/guide-stop-list\?hallId=hall-1/) await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible() + const cards = page.locator('.stop-card') + await expect(cards).toHaveCount(20) + for (const width of [375, 390, 430]) { + await page.setViewportSize({ width, height: 844 }) + const layout = await page.locator('.stop-list').evaluate((list) => { + const cards = Array.from(list.querySelectorAll('.stop-card')) + const listRect = list.getBoundingClientRect() + const firstRect = cards[0].getBoundingClientRect() + const secondRect = cards[1].getBoundingClientRect() + return { + clientWidth: list.clientWidth, + scrollWidth: list.scrollWidth, + listWidth: listRect.width, + first: { x: firstRect.x, width: firstRect.width, bottom: firstRect.bottom }, + second: { x: secondRect.x, y: secondRect.y } + } + }) + expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth) + expect(layout.first.x).toBeCloseTo(layout.second.x, 0) + expect(layout.first.width).toBeCloseTo(layout.listWidth, 0) + expect(layout.second.y).toBeGreaterThan(layout.first.bottom) + } + const search = page.getByRole('textbox') + await search.fill('讲解对象21') + await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible() + await search.fill('') + const audioFilter = page.getByText('有音频', { exact: true }) + await audioFilter.click() + await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible() + await expect(page.getByText('讲解对象2', { exact: true })).not.toBeVisible() + await page.getByText('全部', { exact: true }).click() await page.locator('.explain-scroll').hover() await page.mouse.wheel(0, 10000) await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible() diff --git a/tests/unit/ExplainGuideStopCatalog.spec.ts b/tests/unit/ExplainGuideStopCatalog.spec.ts new file mode 100644 index 0000000..79e1e20 --- /dev/null +++ b/tests/unit/ExplainGuideStopCatalog.spec.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import ExplainGuideStopCatalog from '@/components/explain/ExplainGuideStopCatalog.vue' + +const stops = [ + { id: 'ready', name: '一个很长很长很长很长很长的讲解对象名称', coverImageUrl: '/cover.jpg', audioStatus: 'READY', hasAudio: true, hasTextRecord: true }, + { id: 'text', name: '图文对象', hasTextRecord: true }, + { id: 'none', name: '无内容对象' } +] + +const mountCatalog = () => mount(ExplainGuideStopCatalog, { props: { guideStops: stops, selectedHallName: '宇宙厅', hasMore: true } }) + +describe('ExplainGuideStopCatalog', () => { + it('renders a stable single-column list with real availability copy and no arrows', () => { + const wrapper = mountCatalog() + expect(wrapper.find('.stop-list').exists()).toBe(true) + expect(wrapper.find('.stop-grid').exists()).toBe(false) + expect(wrapper.findAll('.stop-card')).toHaveLength(3) + expect(wrapper.findAll('.stop-cover.placeholder')).toHaveLength(2) + expect(wrapper.text()).toContain('音频可播放') + expect(wrapper.text()).toContain('图文可查看') + expect(wrapper.text()).toContain('暂无可用内容') + expect(wrapper.find('.hall-arrow').exists()).toBe(false) + expect(wrapper.find('.stop-name').classes()).toContain('stop-name') + }) + + it('searches and uses only data-driven fallback filters when guide levels are absent', async () => { + const wrapper = mountCatalog() + expect(wrapper.text()).not.toContain('核心展品') + expect(wrapper.get('input').attributes('placeholder')).toBe('搜索讲解对象') + await wrapper.get('input').setValue('图文') + expect(wrapper.findAll('.stop-card')).toHaveLength(1) + expect(wrapper.emitted('requestAll')).toHaveLength(1) + await wrapper.get('input').setValue('') + await wrapper.get('.filter-chip:nth-child(2)').trigger('tap') + expect(wrapper.findAll('.stop-card')).toHaveLength(1) + }) + + it('keeps loading and empty states available for the single-column list', async () => { + const wrapper = mount(ExplainGuideStopCatalog, { props: { loading: true } }) + expect(wrapper.text()).toContain('正在加载讲解对象') + + await wrapper.setProps({ loading: false, guideStops: [] }) + expect(wrapper.text()).toContain('该展厅暂无讲解对象') + expect(wrapper.find('.stop-list').exists()).toBe(false) + }) + + it('emits card, back, retry, retry-more, and request-all actions', async () => { + const wrapper = mountCatalog() + await wrapper.get('.stop-card').trigger('tap') + await wrapper.get('.header-back').trigger('tap') + await wrapper.get('.filter-chip:nth-child(2)').trigger('tap') + expect(wrapper.emitted('guideStopClick')?.[0][0]).toMatchObject({ id: 'ready' }) + expect(wrapper.emitted('back')).toHaveLength(1) + expect(wrapper.emitted('requestAll')).toHaveLength(1) + + await wrapper.setProps({ error: '失败' }) + await wrapper.get('.state-retry').trigger('tap') + expect(wrapper.emitted('retry')).toHaveLength(1) + await wrapper.setProps({ error: '', loadMoreError: '更多失败' }) + await wrapper.get('.state-retry').trigger('tap') + expect(wrapper.emitted('retryMore')).toHaveLength(1) + }) +})