M5: roll Energy chart window for live auto-refresh (English label); render boolean config fields as switches
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
* benign (jsdom lacks ResizeObserver / SVG layout).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { EnergyCharts } from './EnergyCharts'
|
||||
@@ -254,3 +254,122 @@ describe('EnergyCharts — payload key tolerance', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-refresh: rolling window (A2 — the core bug fix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnergyCharts — auto-refresh rolling window', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
/**
|
||||
* EnergyCharts now passes spanMs (not fixed start/end) to useReadings.
|
||||
* This means each queryFn invocation computes end=now(), giving a rolling
|
||||
* window. We verify here that:
|
||||
* - The readings GET is called with an `end` param (window is computed).
|
||||
* - Rendering twice (to simulate a second fetch via queryFn being called
|
||||
* again) results in a second GET with a later `end` value.
|
||||
*
|
||||
* We test this by manually invoking the apiClient.GET mock twice with a
|
||||
* small real-time delay between them, mimicking what happens when
|
||||
* refetchInterval fires and the queryFn re-runs.
|
||||
*/
|
||||
it('uses spanMs-based rolling window so each queryFn call gets a fresh end=now()', async () => {
|
||||
const endTimestamps: string[] = []
|
||||
|
||||
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
const end = opts?.params?.query?.end
|
||||
if (end) endTimestamps.push(end)
|
||||
return Promise.resolve({ data: READINGS_RESP })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
// First render: triggers initial fetch (end=now at T0).
|
||||
const t0 = Date.now()
|
||||
renderWithProviders(
|
||||
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||
{ initialPath: '/energy' },
|
||||
)
|
||||
|
||||
// Wait for the initial readings GET to fire.
|
||||
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
||||
|
||||
// The recorded `end` must not be before the test started.
|
||||
const firstEndMs = new Date(endTimestamps[0]).getTime()
|
||||
expect(firstEndMs).toBeGreaterThanOrEqual(t0)
|
||||
|
||||
// The readings request must include an `end` query param (window is computed, not omitted).
|
||||
expect(endTimestamps[0]).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not freeze the window on mount — window end advances across separate renders', async () => {
|
||||
// We simulate two independent hook invocations (as if refetch fired).
|
||||
// Since each queryFn call uses new Date(), the end timestamps will advance
|
||||
// naturally with real time. We just need to verify the concept holds.
|
||||
const endTimestamps: string[] = []
|
||||
|
||||
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
const end = opts?.params?.query?.end
|
||||
if (end) endTimestamps.push(end)
|
||||
return Promise.resolve({ data: READINGS_RESP })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
const { unmount } = renderWithProviders(
|
||||
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||
{ initialPath: '/energy' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
||||
|
||||
const firstEnd = new Date(endTimestamps[0]).getTime()
|
||||
|
||||
// Unmount and re-mount to simulate a new queryFn invocation after a tick.
|
||||
unmount()
|
||||
vi.clearAllMocks()
|
||||
endTimestamps.length = 0
|
||||
|
||||
// Small real-time delay to ensure new Date() will be >= first.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
const end = opts?.params?.query?.end
|
||||
if (end) endTimestamps.push(end)
|
||||
return Promise.resolve({ data: READINGS_RESP })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||
{ initialPath: '/energy' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
||||
|
||||
const secondEnd = new Date(endTimestamps[0]).getTime()
|
||||
|
||||
// The second invocation's end must be >= the first — window is not frozen.
|
||||
expect(secondEnd).toBeGreaterThanOrEqual(firstEnd)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,13 +83,6 @@ const TIME_PRESETS: TimePreset[] = [
|
||||
|
||||
const DEFAULT_PRESET = '1h'
|
||||
|
||||
/** Derive ISO start/end strings for a given preset. */
|
||||
function presetWindow(spanMs: number): { start: string; end: string } {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - spanMs)
|
||||
return { start: start.toISOString(), end: end.toISOString() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric colour palette — cycles through a fixed set
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -145,15 +138,16 @@ export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProp
|
||||
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
|
||||
|
||||
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
|
||||
const { start, end } = useMemo(
|
||||
() => presetWindow(selectedPreset.spanMs),
|
||||
// Recompute whenever the preset changes; intentionally excludes Date.now()
|
||||
// so the window doesn't drift on every render — it only resets on tab change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[activePreset],
|
||||
)
|
||||
|
||||
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }, autoRefresh)
|
||||
// Pass spanMs rather than fixed start/end so that every refetchInterval-triggered
|
||||
// queryFn call computes end=now() and rolls the window forward — new readings
|
||||
// recorded after mount will appear without a manual page refresh.
|
||||
// Switching presets changes spanMs → different queryKey → immediate re-fetch.
|
||||
const readingsQuery = useReadings(
|
||||
uuid,
|
||||
{ spanMs: selectedPreset.spanMs, limit: CHART_READINGS_LIMIT },
|
||||
autoRefresh,
|
||||
)
|
||||
const metricsQuery = useMetrics(uuid)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -418,4 +418,78 @@ describe('useReadings', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rolling window: spanMs causes queryFn to compute end=now() on each call
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('when spanMs is provided, sends computed start/end (rolling window) instead of fixed params', async () => {
|
||||
const tBefore = Date.now()
|
||||
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useReadings } = await import('./hooks')
|
||||
const SPAN_MS = 60 * 60 * 1000 // 1 hour
|
||||
const { result } = renderHook(
|
||||
() => useReadings('test-uuid-1', { spanMs: SPAN_MS }),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
const tAfter = Date.now()
|
||||
|
||||
// The GET should have been called with start and end computed from now().
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/modbus/devices/{uuid}/readings',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
end: expect.any(String),
|
||||
start: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const call = mockGet.mock.calls[mockGet.mock.calls.length - 1]
|
||||
const query = call[1].params.query as { start: string; end: string }
|
||||
const endMs = new Date(query.end).getTime()
|
||||
const startMs = new Date(query.start).getTime()
|
||||
|
||||
// end must be within the test window (tBefore..tAfter).
|
||||
expect(endMs).toBeGreaterThanOrEqual(tBefore)
|
||||
expect(endMs).toBeLessThanOrEqual(tAfter + 100) // small tolerance
|
||||
|
||||
// start must be approximately end - spanMs.
|
||||
expect(endMs - startMs).toBeCloseTo(SPAN_MS, -2) // within 100ms
|
||||
})
|
||||
|
||||
it('when spanMs is provided, queryKey uses spanMs (not absolute timestamps) for stable caching', async () => {
|
||||
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||
|
||||
const { Wrapper, qc } = makeWrapper()
|
||||
const { useReadings } = await import('./hooks')
|
||||
const SPAN_MS = 3600000 // 1 hour
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useReadings('test-uuid-1', { spanMs: SPAN_MS }),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
// The query cache should have a key containing spanMs, not an absolute timestamp.
|
||||
const queries = qc.getQueryCache().findAll({
|
||||
predicate: (q) => {
|
||||
const key = q.queryKey as unknown[]
|
||||
return key[0] === 'modbus-readings' && key[1] === 'test-uuid-1'
|
||||
},
|
||||
})
|
||||
expect(queries).toHaveLength(1)
|
||||
const keyParams = queries[0].queryKey[2] as Record<string, unknown>
|
||||
expect(keyParams).toHaveProperty('spanMs', SPAN_MS)
|
||||
expect(keyParams).not.toHaveProperty('start')
|
||||
expect(keyParams).not.toHaveProperty('end')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,13 @@ export type ModbusReadingsResponse = components['schemas']['ModbusReadingsRespon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReadingsQueryParams {
|
||||
/**
|
||||
* How many milliseconds of history to show. When provided the queryFn
|
||||
* computes end=now() and start=now()-spanMs on every invocation, so
|
||||
* refetchInterval-triggered re-fetches always use a rolling window.
|
||||
* Mutually exclusive with `start`/`end`.
|
||||
*/
|
||||
spanMs?: number
|
||||
start?: string | null
|
||||
end?: string | null
|
||||
/** Capped server-side; default max is 1000 to avoid pulling full history. */
|
||||
@@ -196,22 +203,39 @@ export function useReadings(
|
||||
params: ReadingsQueryParams,
|
||||
options?: AutoRefreshOptions,
|
||||
) {
|
||||
const { start, end, limit } = params
|
||||
const { spanMs, start, end, limit } = params
|
||||
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
|
||||
|
||||
const refetchInterval = options?.refetchIntervalMs != null
|
||||
? Math.max(2_000, options.refetchIntervalMs)
|
||||
: undefined
|
||||
|
||||
// When spanMs is provided, the query key uses the stable span value (not
|
||||
// absolute timestamps), so switching presets triggers a fresh fetch while
|
||||
// refetchInterval-triggered re-fetches reuse the cached key and run the
|
||||
// queryFn again — computing end=now() each time, giving a rolling window.
|
||||
const queryKey = spanMs != null
|
||||
? ['modbus-readings', uuid, { spanMs, limit: effectiveLimit }]
|
||||
: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }]
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
let resolvedStart = start
|
||||
let resolvedEnd = end
|
||||
|
||||
if (spanMs != null) {
|
||||
const now = new Date()
|
||||
resolvedEnd = now.toISOString()
|
||||
resolvedStart = new Date(now.getTime() - spanMs).toISOString()
|
||||
}
|
||||
|
||||
const res = await apiClient.GET('/api/modbus/devices/{uuid}/readings', {
|
||||
params: {
|
||||
path: { uuid },
|
||||
query: {
|
||||
...(start ? { start } : {}),
|
||||
...(end ? { end } : {}),
|
||||
...(resolvedStart ? { start: resolvedStart } : {}),
|
||||
...(resolvedEnd ? { end: resolvedEnd } : {}),
|
||||
limit: effectiveLimit,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user