From 6c0123c5d2e02b5e52c8961310e44638892a282b Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 31 Mar 2026 13:24:54 +0800 Subject: [PATCH] refactor(overview): update imports and enhance chart and card views with new types and tests - Changed import paths for app card and chart types to use the new app-chart-types module. - Refactored state initialization in ChartView for improved readability. - Added unit tests for CardView, ChartView, and LongTimeRangePicker components to ensure functionality and reliability. - Introduced new test cases for time range picker and tracing components to enhance test coverage. --- .../overview/__tests__/card-view.spec.tsx | 272 ++++++++++ .../overview/__tests__/chart-view.spec.tsx | 127 +++++ .../long-time-range-picker-branches.spec.tsx | 68 +++ .../__tests__/long-time-range-picker.spec.tsx | 97 ++++ .../[appId]/overview/__tests__/page.spec.tsx | 30 + .../[appId]/overview/card-view.tsx | 2 +- .../[appId]/overview/chart-view.tsx | 13 +- .../overview/long-time-range-picker.tsx | 2 +- .../__tests__/date-picker-branches.spec.tsx | 78 +++ .../__tests__/date-picker.spec.tsx | 88 +++ .../__tests__/index.spec.tsx | 144 +++++ .../__tests__/range-selector.spec.tsx | 73 +++ .../overview/time-range-picker/index.tsx | 5 +- .../time-range-picker/range-selector.tsx | 9 +- .../tracing/__tests__/config-button.spec.tsx | 81 +++ .../tracing/__tests__/config-popup.spec.tsx | 231 ++++++++ .../overview/tracing/__tests__/field.spec.tsx | 40 ++ .../overview/tracing/__tests__/panel.spec.tsx | 239 ++++++++ .../__tests__/provider-config-modal.spec.tsx | 366 +++++++++++++ .../tracing/__tests__/provider-panel.spec.tsx | 122 +++++ .../svg-attribute-error-reproduction.spec.tsx | 2 + .../tracing/__tests__/tracing-icon.spec.tsx | 17 + web/app/components/app/overview/app-card.tsx | 441 --------------- .../app-card/__tests__/index.spec.tsx | 319 +++++++++++ .../app-card/__tests__/sections.spec.tsx | 197 +++++++ .../__tests__/toggle-logic.test.ts | 13 +- .../app-card/__tests__/use-app-card.spec.tsx | 255 +++++++++ .../app/overview/app-card/index.tsx | 157 ++++++ .../app/overview/app-card/sections.tsx | 320 +++++++++++ .../components/app/overview/app-card/types.ts | 19 + .../app/overview/app-card/use-app-card.ts | 264 +++++++++ web/app/components/app/overview/app-chart.tsx | 512 ------------------ .../overview/chart/__tests__/metrics.spec.tsx | 178 ++++++ .../overview/chart/__tests__/utils.spec.ts | 154 ++++++ .../components/app/overview/chart/core.tsx | 98 ++++ .../components/app/overview/chart/metrics.tsx | 287 ++++++++++ .../components/app/overview/chart/types.ts | 24 + .../components/app/overview/chart/utils.ts | 268 +++++++++ .../item/use-generation-item-utils.ts | 61 +++ .../text-generate/item/use-generation-item.ts | 69 +-- 40 files changed, 4710 insertions(+), 1032 deletions(-) create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker-branches.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/page.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker-branches.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/index.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/range-selector.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/config-button.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/config-popup.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/field.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-panel.spec.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/tracing-icon.spec.tsx delete mode 100644 web/app/components/app/overview/app-card.tsx create mode 100644 web/app/components/app/overview/app-card/__tests__/index.spec.tsx create mode 100644 web/app/components/app/overview/app-card/__tests__/sections.spec.tsx rename web/app/components/app/overview/{ => app-card}/__tests__/toggle-logic.test.ts (96%) create mode 100644 web/app/components/app/overview/app-card/__tests__/use-app-card.spec.tsx create mode 100644 web/app/components/app/overview/app-card/index.tsx create mode 100644 web/app/components/app/overview/app-card/sections.tsx create mode 100644 web/app/components/app/overview/app-card/types.ts create mode 100644 web/app/components/app/overview/app-card/use-app-card.ts delete mode 100644 web/app/components/app/overview/app-chart.tsx create mode 100644 web/app/components/app/overview/chart/__tests__/metrics.spec.tsx create mode 100644 web/app/components/app/overview/chart/__tests__/utils.spec.ts create mode 100644 web/app/components/app/overview/chart/core.tsx create mode 100644 web/app/components/app/overview/chart/metrics.tsx create mode 100644 web/app/components/app/overview/chart/types.ts create mode 100644 web/app/components/app/overview/chart/utils.ts create mode 100644 web/app/components/app/text-generate/item/use-generation-item-utils.ts diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx new file mode 100644 index 00000000000..8c0ddad797b --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx @@ -0,0 +1,272 @@ +import type { App } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { AppModeEnum } from '@/types/app' +import CardView from '../card-view' + +const mockToast = vi.fn() +const mockFetchAppDetail = vi.fn() +const mockUpdateAppSiteStatus = vi.fn() +const mockUpdateAppSiteConfig = vi.fn() +const mockUpdateAppSiteAccessToken = vi.fn() +const mockSocketEmit = vi.fn() +const mockGetSocket = vi.fn() +const mockOnAppStateUpdate = vi.fn() + +const cardState = vi.hoisted(() => ({ + appDetail: null as null | { id: string, mode: string, name?: string }, + workflow: null as null | { graph?: { nodes?: Array<{ data?: { type?: string } }> } }, + setAppDetail: vi.fn(), + collaborationCallback: null as null | (() => Promise), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: typeof cardState.appDetail, setAppDetail: typeof cardState.setAppDetail }) => unknown) => selector({ + appDetail: cardState.appDetail, + setAppDetail: cardState.setAppDetail, + }), +})) + +vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: () => ({ data: cardState.workflow }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + +vi.mock('@/app/components/app/overview/app-card', () => ({ + default: ({ + cardType, + triggerModeDisabled, + onChangeStatus, + onGenerateCode, + onSaveSiteConfig, + }: { + cardType: string + triggerModeDisabled?: boolean + onChangeStatus?: (value: boolean) => void + onGenerateCode?: () => void + onSaveSiteConfig?: (params: Record) => void + }) => ( +
+
{`app-card:${cardType}:${triggerModeDisabled ? 'disabled' : 'enabled'}`}
+ {onChangeStatus && } + {onGenerateCode && } + {onSaveSiteConfig && } +
+ ), +})) + +vi.mock('@/app/components/app/overview/trigger-card', () => ({ + default: ({ onToggleResult }: { onToggleResult: (error: Error | null, message?: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/tools/mcp/mcp-service-card', () => ({ + default: ({ triggerModeDisabled }: { triggerModeDisabled?: boolean }) =>
{`mcp-card:${triggerModeDisabled ? 'disabled' : 'enabled'}`}
, +})) + +vi.mock('@/app/components/workflow/types', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isTriggerNode: (type: string) => type === 'trigger', + } +}) + +vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ + webSocketClient: { + getSocket: (...args: unknown[]) => mockGetSocket(...args), + }, +})) + +vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + onAppStateUpdate: (...args: unknown[]) => mockOnAppStateUpdate(...args), + }, +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), + updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args), + updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args), + updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args), +})) + +const createApp = (overrides: Partial = {}): App => ({ + id: 'app-1', + mode: 'chat', + name: 'App 1', + description: '', + icon: '', + icon_background: '', + icon_type: 'emoji', + enable_site: false, + enable_api: false, + created_at: 0, + updated_at: 0, + ...overrides, +} as App) + +describe('OverviewRouteCardView', () => { + beforeEach(() => { + vi.clearAllMocks() + cardState.appDetail = null + cardState.workflow = null + cardState.collaborationCallback = null + cardState.setAppDetail.mockReset() + mockFetchAppDetail.mockResolvedValue(createApp({ mode: AppModeEnum.CHAT })) + mockUpdateAppSiteStatus.mockResolvedValue(createApp()) + mockUpdateAppSiteConfig.mockResolvedValue(createApp()) + mockUpdateAppSiteAccessToken.mockResolvedValue({ access_token: 'token-1' }) + mockGetSocket.mockReturnValue({ emit: mockSocketEmit }) + mockOnAppStateUpdate.mockImplementation((callback: () => Promise) => { + cardState.collaborationCallback = callback + return vi.fn() + }) + localStorage.clear() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render loading when app details are unavailable', () => { + render() + + expect(screen.getByText('loading')).toBeInTheDocument() + }) + + it('should render workflow trigger mode cards inside the panel', () => { + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.WORKFLOW } + cardState.workflow = { graph: { nodes: [{ data: { type: 'trigger' } }] } } + + render() + + expect(screen.getByText('app-card:webapp:disabled')).toBeInTheDocument() + expect(screen.getByText('app-card:api:disabled')).toBeInTheDocument() + expect(screen.getByText('mcp-card:disabled')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'trigger-card' })).toBeInTheDocument() + }) + + it('should disable app cards while workflow details are still loading', () => { + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.WORKFLOW } + cardState.workflow = null + + render() + + expect(screen.getByText('app-card:webapp:disabled')).toBeInTheDocument() + expect(screen.getByText('app-card:api:disabled')).toBeInTheDocument() + expect(screen.getByText('mcp-card:disabled')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'trigger-card' })).toBeInTheDocument() + }) + + it('should call app actions, refresh details, and emit collaboration events on success', async () => { + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } + + render() + + fireEvent.click(screen.getByRole('button', { name: 'toggle:webapp' })) + fireEvent.click(screen.getByRole('button', { name: 'toggle:api' })) + fireEvent.click(screen.getByRole('button', { name: 'save-config:webapp' })) + fireEvent.click(screen.getByRole('button', { name: 'generate:webapp' })) + + await waitFor(() => { + expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({ + url: '/apps/app-1/site-enable', + body: { enable_site: true }, + }) + }) + + expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({ + url: '/apps/app-1/api-enable', + body: { enable_api: true }, + }) + expect(mockUpdateAppSiteConfig).toHaveBeenCalledWith({ + url: '/apps/app-1/site', + body: { title: 'site-title' }, + }) + expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({ + url: '/apps/app-1/site/access-token-reset', + }) + expect(localStorage.getItem('needRefreshAppList')).toBe('1') + expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + expect(cardState.setAppDetail).toHaveBeenCalled() + expect(mockSocketEmit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({ + type: 'app_state_update', + })) + expect(mockToast).toHaveBeenCalledWith(expect.stringMatching(/common\.actionMsg\./), expect.objectContaining({ type: 'success' })) + }) + + it('should show an error toast when site status updates fail', async () => { + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } + mockUpdateAppSiteStatus.mockRejectedValueOnce(new Error('failed')) + + render() + fireEvent.click(screen.getByRole('button', { name: 'toggle:webapp' })) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith(expect.stringMatching(/common\.actionMsg\./), { type: 'error' }) + }) + }) + + it('should log fetch errors when refreshing app details after successful mutations', async () => { + const fetchError = new Error('fetch failed') + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } + mockFetchAppDetail.mockRejectedValue(fetchError) + + render() + fireEvent.click(screen.getByRole('button', { name: 'toggle:webapp' })) + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith(fetchError) + }) + }) + + it('should skip collaboration subscriptions when appId is missing', () => { + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } + + render() + + expect(mockOnAppStateUpdate).not.toHaveBeenCalled() + }) + + it('should refresh details when the collaboration listener fires', async () => { + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } + + const { unmount } = render() + + await waitFor(() => expect(mockOnAppStateUpdate).toHaveBeenCalled()) + await cardState.collaborationCallback?.() + + expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + + unmount() + }) + + it('should log collaboration refresh errors when error logging fails inside the refresh helper', async () => { + const refreshError = new Error('refresh failed') + const loggerError = new Error('logger failed') + const consoleErrorSpy = vi.spyOn(console, 'error') + .mockImplementationOnce(() => { + throw loggerError + }) + .mockImplementation(() => {}) + cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } + mockFetchAppDetail.mockRejectedValueOnce(refreshError) + + render() + + await waitFor(() => expect(mockOnAppStateUpdate).toHaveBeenCalled()) + await cardState.collaborationCallback?.() + + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, refreshError) + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, 'app state update failed:', loggerError) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx new file mode 100644 index 00000000000..de948774a79 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import ChartView from '../chart-view' + +const mockState = vi.hoisted(() => ({ + appDetail: null as null | { mode: string }, + isCloudEdition: true, +})) + +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return mockState.isCloudEdition + }, +})) + +vi.mock('@/app/components/app/log/filter', () => ({ + TIME_PERIOD_MAPPING: { + default: { value: -1, name: 'allTime' }, + }, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: typeof mockState.appDetail }) => unknown) => selector({ + appDetail: mockState.appDetail, + }), +})) + +vi.mock('../time-range-picker', () => ({ + default: ({ onSelect }: { onSelect: (payload: { name: string, query: { start: string, end: string } }) => void }) => ( + + ), +})) + +vi.mock('../long-time-range-picker', () => ({ + default: ({ onSelect }: { onSelect: (payload: { name: string, query?: { start: string, end: string } }) => void }) => ( + + ), +})) + +vi.mock('@/app/components/app/overview/chart/metrics', () => { + const createChart = (label: string) => ({ + default: ({ id, period }: { id: string, period: { name: string } }) =>
{`${label}:${id}:${period.name}`}
, + }).default + + return { + ConversationsChart: createChart('conversations'), + EndUsersChart: createChart('end-users'), + AvgSessionInteractions: createChart('avg-session'), + AvgResponseTime: createChart('avg-response'), + TokenPerSecond: createChart('tps'), + UserSatisfactionRate: createChart('satisfaction'), + CostChart: createChart('cost'), + MessagesChart: createChart('messages'), + WorkflowMessagesChart: createChart('workflow-messages'), + WorkflowDailyTerminalsChart: createChart('workflow-terminals'), + WorkflowCostChart: createChart('workflow-cost'), + AvgUserInteractions: createChart('avg-user'), + } +}) + +describe('OverviewRouteChartView', () => { + beforeEach(() => { + vi.clearAllMocks() + mockState.appDetail = null + mockState.isCloudEdition = true + }) + + it('should render nothing when app detail is unavailable', () => { + const { container } = render(header} />) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render cloud chat metrics and update period from the short picker', () => { + mockState.appDetail = { mode: 'chat' } + + render(header-right} />) + + expect(screen.getByText('common.appMenus.overview')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'cloud-picker' })).toBeInTheDocument() + expect(screen.getByText('conversations:app-1:appLog.filter.period.today')).toBeInTheDocument() + expect(screen.getByText('avg-session:app-1:appLog.filter.period.today')).toBeInTheDocument() + expect(screen.getByText('messages:app-1:appLog.filter.period.today')).toBeInTheDocument() + expect(screen.getByText('header-right')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'cloud-picker' })) + + expect(screen.getByText('conversations:app-1:cloud-range')).toBeInTheDocument() + expect(screen.queryByText(/workflow-messages/)).not.toBeInTheDocument() + }) + + it('should render completion metrics without chat-only charts', () => { + mockState.appDetail = { mode: 'completion' } + + render(header-right} />) + + expect(screen.getByText('avg-response:app-2:appLog.filter.period.today')).toBeInTheDocument() + expect(screen.queryByText(/avg-session:app-2/)).not.toBeInTheDocument() + expect(screen.queryByText(/messages:app-2/)).not.toBeInTheDocument() + }) + + it('should render workflow metrics and use the long-range picker outside cloud mode', () => { + mockState.appDetail = { mode: 'workflow' } + mockState.isCloudEdition = false + + render(header-right} />) + + expect(screen.getByRole('button', { name: 'long-picker' })).toBeInTheDocument() + expect(screen.getByText('workflow-messages:app-3:appLog.filter.period.last7days')).toBeInTheDocument() + expect(screen.getByText('workflow-cost:app-3:appLog.filter.period.last7days')).toBeInTheDocument() + expect(screen.queryByText(/conversations:app-3/)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'long-picker' })) + + expect(screen.getByText('workflow-messages:app-3:long-range')).toBeInTheDocument() + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker-branches.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker-branches.spec.tsx new file mode 100644 index 00000000000..d367df0cdee --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker-branches.spec.tsx @@ -0,0 +1,68 @@ +import type { Item } from '@/app/components/base/select' +import { fireEvent, render, screen } from '@testing-library/react' +import dayjs from 'dayjs' +import LongTimeRangePicker from '../long-time-range-picker' + +const simpleSelectState = vi.hoisted(() => ({ + items: [] as Item[], + onSelect: null as null | ((item: Item) => void), +})) + +vi.mock('@/app/components/base/select', () => ({ + SimpleSelect: ({ + items, + onSelect, + }: { + items: Item[] + onSelect: (item: Item) => void + }) => { + simpleSelectState.items = items + simpleSelectState.onSelect = onSelect + + return ( +
+ +
+ ) + }, +})) + +describe('OverviewRouteLongTimeRangePickerFallbackBranches', () => { + beforeEach(() => { + vi.clearAllMocks() + simpleSelectState.items = [] + simpleSelectState.onSelect = null + }) + + it('should keep using the fallback callback payload when the select callback receives an unmapped item', () => { + const onSelect = vi.fn() + + render( + , + ) + + expect(simpleSelectState.items).toHaveLength(2) + + fireEvent.click(screen.getByRole('button', { name: 'trigger-fallback-period' })) + + expect(onSelect).toHaveBeenCalledWith({ + name: 'appLog.filter.period.allTime', + query: { + start: dayjs().subtract(-1, 'day').startOf('day').format('YYYY-MM-DD'), + end: dayjs().endOf('day').format('YYYY-MM-DD'), + }, + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker.spec.tsx new file mode 100644 index 00000000000..903be4d71e0 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/long-time-range-picker.spec.tsx @@ -0,0 +1,97 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' +import LongTimeRangePicker from '../long-time-range-picker' + +const periodMapping = { + '-1': { value: -1, name: 'allTime' as const }, + '0': { value: 0, name: 'today' as const }, + '2': { value: 30, name: 'last30days' as const }, +} + +describe('OverviewRouteLongTimeRangePicker', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the translated default option', () => { + render( + , + ) + + expect(screen.getByText('appLog.filter.period.last30days')).toBeInTheDocument() + }) + + it('should emit an all-time selection without query params', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.allTime')) + + expect(onSelect).toHaveBeenCalledWith({ + name: 'appLog.filter.period.allTime', + query: undefined, + }) + }) + + it('should emit a today selection with start and end of day', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.today')) + + expect(onSelect).toHaveBeenCalledWith({ + name: 'appLog.filter.period.today', + query: { + start: dayjs().startOf('day').format('YYYY-MM-DD'), + end: dayjs().endOf('day').format('YYYY-MM-DD'), + }, + }) + }) + + it('should emit a relative time window for normal period selections', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.last30days')) + + expect(onSelect).toHaveBeenCalledWith({ + name: 'appLog.filter.period.last30days', + query: { + start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD'), + end: dayjs().endOf('day').format('YYYY-MM-DD'), + }, + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/page.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/page.spec.tsx new file mode 100644 index 00000000000..45c541ff9b3 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/page.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' + +import OverviewPage from '../page' + +vi.mock('@/app/components/app/overview/apikey-info-panel', () => ({ + default: () =>
apikey-info-panel
, +})) + +vi.mock('../chart-view', () => ({ + default: ({ appId, headerRight }: { appId: string, headerRight: React.ReactNode }) => ( +
+
{`chart-view:${appId}`}
+
{headerRight}
+
+ ), +})) + +vi.mock('../tracing/panel', () => ({ + default: () =>
tracing-panel
, +})) + +describe('OverviewRoutePage', () => { + it('should resolve params and compose the overview page layout', async () => { + render(await OverviewPage({ params: Promise.resolve({ appId: 'app-123' }) })) + + expect(screen.getByText('apikey-info-panel')).toBeInTheDocument() + expect(screen.getByText('chart-view:app-123')).toBeInTheDocument() + expect(screen.getByText('tracing-panel')).toBeInTheDocument() + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index f0d21be9100..6ea8928abf9 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { IAppCardProps } from '@/app/components/app/overview/app-card' +import type { IAppCardProps } from '@/app/components/app/overview/app-card/types' import type { BlockEnum } from '@/app/components/workflow/types' import type { UpdateAppSiteCodeResponse } from '@/models/app' import type { App } from '@/types/app' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index e4f456e49f4..fe2478b109d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -1,5 +1,5 @@ 'use client' -import type { PeriodParams } from '@/app/components/app/overview/app-chart' +import type { PeriodParams } from '@/app/components/app/overview/chart/types' import type { I18nKeysByPrefix } from '@/types/i18n' import dayjs from 'dayjs' import quarterOfYear from 'dayjs/plugin/quarterOfYear' @@ -7,7 +7,7 @@ import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter' -import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart' +import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/chart/metrics' import { useStore as useAppStore } from '@/app/components/app/store' import { IS_CLOUD_EDITION } from '@/config' import LongTimeRangePicker from './long-time-range-picker' @@ -37,10 +37,11 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) { const appDetail = useAppStore(state => state.appDetail) const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow' const isWorkflow = appDetail?.mode === 'workflow' - const [period, setPeriod] = useState(IS_CLOUD_EDITION - ? { name: t('filter.period.today', { ns: 'appLog' }), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } } - : { name: t('filter.period.last7days', { ns: 'appLog' }), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }, - ) + const [period, setPeriod] = useState(() => ( + IS_CLOUD_EDITION + ? { name: t('filter.period.today', { ns: 'appLog' }), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } } + : { name: t('filter.period.last7days', { ns: 'appLog' }), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } } + )) if (!appDetail) return null diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx index f7178d7ac21..536a0c04c1f 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { PeriodParams } from '@/app/components/app/overview/app-chart' +import type { PeriodParams } from '@/app/components/app/overview/chart/types' import type { Item } from '@/app/components/base/select' import type { I18nKeysByPrefix } from '@/types/i18n' import dayjs from 'dayjs' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker-branches.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker-branches.spec.tsx new file mode 100644 index 00000000000..571c175c6ac --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker-branches.spec.tsx @@ -0,0 +1,78 @@ +import type { Dayjs } from 'dayjs' +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import dayjs from 'dayjs' +import DatePicker from '../date-picker' + +const pickerState = vi.hoisted(() => ({ + disabledChecks: {} as Record, + startValue: undefined as Dayjs | undefined, +})) + +vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({ + default: ({ + value, + renderTrigger, + getIsDateDisabled, + }: { + value?: Dayjs + renderTrigger: (props: { + value?: Dayjs + handleClickTrigger: () => void + isOpen: boolean + }) => ReactNode + getIsDateDisabled: (date: Dayjs) => boolean + }) => { + const id = Object.keys(pickerState.disabledChecks).length === 0 ? 'start' : 'end' + const triggerValue = id === 'start' ? undefined : value + if (id === 'start') + pickerState.startValue = value + const trigger = renderTrigger({ + value: triggerValue, + handleClickTrigger: () => {}, + isOpen: false, + }) + + pickerState.disabledChecks[id] = id === 'start' + ? getIsDateDisabled(dayjs().add(1, 'day')) + : getIsDateDisabled(dayjs(pickerState.startValue).add(30, 'day')) + + return ( +
+
{trigger}
+
+ ) + }, +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/utils/format', async () => { + const actual = await vi.importActual('@/utils/format') + return { + ...actual, + formatToLocalTime: (value: Dayjs, _locale: string, format: string) => value.format(format), + } +}) + +describe('OverviewRouteDatePickerFallbackBranches', () => { + beforeEach(() => { + vi.clearAllMocks() + pickerState.disabledChecks = {} + pickerState.startValue = undefined + }) + + it('should render an empty trigger for missing values and allow the 30-day end boundary', () => { + const start = dayjs('2026-01-01') + const end = dayjs('2026-01-10') + + render() + + expect(screen.getByTestId('start-trigger')).toHaveTextContent(/^$/) + expect(screen.getByTestId('end-trigger')).toHaveTextContent('Jan 10') + expect(pickerState.disabledChecks.start).toBe(true) + expect(pickerState.disabledChecks.end).toBe(false) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker.spec.tsx new file mode 100644 index 00000000000..0c3f7b49d51 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/date-picker.spec.tsx @@ -0,0 +1,88 @@ +import type { Dayjs } from 'dayjs' +import { fireEvent, render, screen } from '@testing-library/react' +import dayjs from 'dayjs' +import DatePicker from '../date-picker' + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/utils/format', async () => { + const actual = await vi.importActual('@/utils/format') + return { + ...actual, + formatToLocalTime: (value: Dayjs, _locale: string, format: string) => value.format(format), + } +}) + +const renderComponent = (overrides: Partial> = {}) => { + const props: React.ComponentProps = { + start: dayjs().subtract(1, 'day'), + end: dayjs(), + onStartChange: vi.fn(), + onEndChange: vi.fn(), + ...overrides, + } + + render() + + return props +} + +describe('OverviewRouteDatePicker', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render formatted start and end triggers', () => { + const start = dayjs('2026-01-10') + const end = dayjs('2026-01-15') + + renderComponent({ start, end }) + + expect(screen.getByText('Jan 10')).toBeInTheDocument() + expect(screen.getByText('Jan 15')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should open the start picker and notify when a new start date is selected', () => { + const onStartChange = vi.fn() + const end = dayjs() + + renderComponent({ + start: end.subtract(1, 'day'), + end, + onStartChange, + }) + + fireEvent.click(screen.getByText(end.subtract(1, 'day').format('MMM D'))) + expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0) + + fireEvent.click(screen.getByRole('button', { name: `${end.date()}` })) + + expect(onStartChange).toHaveBeenCalledTimes(1) + expect(dayjs(onStartChange.mock.calls[0][0]).isSame(end, 'date')).toBe(true) + }) + + it('should open the end picker and notify when a new end date is selected', () => { + const onEndChange = vi.fn() + const start = dayjs().subtract(1, 'day') + + renderComponent({ + start, + end: dayjs(), + onEndChange, + }) + + fireEvent.click(screen.getByText(dayjs().format('MMM D'))) + expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0) + + fireEvent.click(screen.getByRole('button', { name: `${start.date()}` })) + + expect(onEndChange).toHaveBeenCalledTimes(1) + expect(dayjs(onEndChange.mock.calls[0][0]).isSame(start, 'date')).toBe(true) + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/index.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/index.spec.tsx new file mode 100644 index 00000000000..64d01bd5889 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/index.spec.tsx @@ -0,0 +1,144 @@ +import type { Dayjs } from 'dayjs' +import { fireEvent, render, screen } from '@testing-library/react' +import dayjs from 'dayjs' +import TimeRangePicker from '../index' + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/utils/format', async () => { + const actual = await vi.importActual('@/utils/format') + return { + ...actual, + formatToLocalTime: (value: Dayjs, _locale: string, format: string) => value.format(format), + } +}) + +vi.mock('../date-picker', () => ({ + default: ({ + start, + end, + onStartChange, + onEndChange, + }: { + start: Dayjs + end: Dayjs + onStartChange: (date?: Dayjs) => void + onEndChange: (date?: Dayjs) => void + }) => ( +
+
{`${start.format('MMM D')} - ${end.format('MMM D')}`}
+ + + + + +
+ ), +})) + +vi.mock('../range-selector', () => ({ + default: ({ + isCustomRange, + onSelect, + }: { + isCustomRange: boolean + onSelect: (payload: { name: string, query: { start: Dayjs, end: Dayjs } }) => void + }) => ( +
+
{isCustomRange ? 'custom' : 'preset'}
+ +
+ ), +})) + +describe('OverviewRouteTimeRangePicker', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should format a preset range selection and keep preset mode', () => { + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'select-range' })) + + expect(onSelect).toHaveBeenCalledWith({ + name: 'appLog.filter.period.last7days', + query: { + start: '2026-03-01', + end: '2026-03-08', + }, + }) + expect(screen.getByTestId('range-mode')).toHaveTextContent('preset') + }) + + it('should ignore empty or unchanged date updates', () => { + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'skip-start' })) + fireEvent.click(screen.getByRole('button', { name: 'same-start' })) + fireEvent.click(screen.getByRole('button', { name: 'same-end' })) + + expect(onSelect).not.toHaveBeenCalled() + expect(screen.getByTestId('range-mode')).toHaveTextContent('preset') + }) + + it('should format custom date changes and switch to custom mode', () => { + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'change-start' })) + fireEvent.click(screen.getByRole('button', { name: 'change-end' })) + + expect(onSelect).toHaveBeenNthCalledWith(1, { + name: `${dayjs().subtract(1, 'day').format('MMM D')} - ${dayjs().format('MMM D')}`, + query: { + start: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), + end: dayjs().format('YYYY-MM-DD'), + }, + }) + expect(onSelect).toHaveBeenNthCalledWith(2, { + name: `${dayjs().subtract(1, 'day').format('MMM D')} - ${dayjs().add(1, 'day').format('MMM D')}`, + query: { + start: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), + end: dayjs().add(1, 'day').format('YYYY-MM-DD'), + }, + }) + expect(screen.getByTestId('range-mode')).toHaveTextContent('custom') + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/range-selector.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/range-selector.spec.tsx new file mode 100644 index 00000000000..53119ced4c9 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/__tests__/range-selector.spec.tsx @@ -0,0 +1,73 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' +import RangeSelector from '../range-selector' + +const ranges = [ + { value: 0, name: 'today' as const }, + { value: 7, name: 'last7days' as const }, +] + +describe('OverviewRouteRangeSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the custom range label when custom mode is active', () => { + render( + , + ) + + expect(screen.getByText('appLog.filter.period.custom')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should select the today option and emit a single-day range', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.today')) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect.mock.calls[0][0].name).toBe('appLog.filter.period.today') + expect(onSelect.mock.calls[0][0].query.start.isSame(dayjs().startOf('day'))).toBe(true) + expect(onSelect.mock.calls[0][0].query.end.isSame(dayjs().endOf('day'))).toBe(true) + }) + + it('should select a relative range and emit the computed query window', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.last7days')) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect.mock.calls[0][0].name).toBe('appLog.filter.period.last7days') + expect(onSelect.mock.calls[0][0].query.start.isSame(dayjs().subtract(7, 'day').startOf('day'))).toBe(true) + expect(onSelect.mock.calls[0][0].query.end.isSame(dayjs().endOf('day'))).toBe(true) + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx index 53794ad8dba..255ee710565 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx @@ -1,12 +1,11 @@ 'use client' import type { Dayjs } from 'dayjs' import type { FC } from 'react' -import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart' +import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/chart/types' import type { I18nKeysByPrefix } from '@/types/i18n' import dayjs from 'dayjs' import * as React from 'react' import { useCallback, useState } from 'react' -import { HourglassShape } from '@/app/components/base/icons/src/vender/other' import { useLocale } from '@/context/i18n' import { formatToLocalTime } from '@/utils/format' import DatePicker from './date-picker' @@ -80,7 +79,7 @@ const TimeRangePicker: FC = ({ ranges={ranges} onSelect={handleRangeChange} /> - +