Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/Frontend/src/components/AutoRefreshIndicator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/vue";
import AutoRefreshIndicator from "@/components/AutoRefreshIndicator.vue";

describe("FEATURE: Auto refresh indicator", () => {
test("EXAMPLE: Nothing is rendered while auto refresh is off", () => {
render(AutoRefreshIndicator, { props: { nextRefreshAt: null, intervalMs: 5000, refreshing: false } });

expect(screen.queryByTestId("auto-refresh-indicator")).not.toBeInTheDocument();
});

test("EXAMPLE: A countdown bar is shown while waiting for the next refresh", () => {
render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now() + 2500, intervalMs: 5000, refreshing: false } });

expect(screen.getByTestId("auto-refresh-indicator")).toBeInTheDocument();
const bar = screen.getByTestId("auto-refresh-countdown");
const width = parseFloat(bar.style.width);
expect(width).toBeGreaterThan(25);
expect(width).toBeLessThan(75);
});

test("EXAMPLE: A waiting state is shown when the refresh is due but the query is still running", () => {
render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now(), intervalMs: 5000, refreshing: true } });

expect(screen.getByTestId("auto-refresh-waiting")).toBeInTheDocument();
expect(screen.queryByTestId("auto-refresh-countdown")).not.toBeInTheDocument();
});
});
68 changes: 68 additions & 0 deletions src/Frontend/src/components/AutoRefreshIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";

const props = defineProps<{ nextRefreshAt: number | null; intervalMs: number | null; refreshing: boolean }>();

const now = ref(Date.now());
let timer: number | undefined;

onMounted(() => {
timer = window.setInterval(() => (now.value = Date.now()), 250);
});

onBeforeUnmount(() => window.clearInterval(timer));

const fraction = computed(() => {
if (props.nextRefreshAt === null || !props.intervalMs) return null;
const remaining = props.nextRefreshAt - now.value;
return Math.min(1, Math.max(0, remaining / props.intervalMs));
});

const secondsLeft = computed(() => (props.nextRefreshAt === null ? null : Math.max(0, Math.ceil((props.nextRefreshAt - now.value) / 1000))));
</script>

<template>
<div
v-if="fraction !== null"
class="auto-refresh-indicator"
role="timer"
:aria-label="refreshing ? 'Waiting for query results' : `Auto refresh in ${secondsLeft} seconds`"
:title="refreshing ? 'Waiting for query results…' : `Auto refresh in ${secondsLeft}s`"
data-testid="auto-refresh-indicator"
>
<div v-if="refreshing" class="bar waiting" data-testid="auto-refresh-waiting" />
<div v-else class="bar" :style="{ width: `${fraction * 100}%` }" data-testid="auto-refresh-countdown" />
</div>
</template>

<style scoped>
.auto-refresh-indicator {
width: 100%;
height: 3px;
margin-top: 0.25rem;
background-color: #e0e0e0;
border-radius: 2px;
overflow: hidden;
}

.bar {
height: 100%;
background-color: #00729c;
transition: width 250ms linear;
}

.bar.waiting {
width: 100%;
animation: waiting-pulse 1.2s ease-in-out infinite;
}

@keyframes waiting-pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 0.9;
}
}
</style>
7 changes: 4 additions & 3 deletions src/Frontend/src/components/RefreshConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ function renderRefreshConfig(queryInProgress: boolean) {
// ==================== Tests ====================

describe("FEATURE: Refresh Controls Query State", () => {
describe("RULE: Refresh controls are disabled while a query is in progress", () => {
test("EXAMPLE: Refresh controls reflect queryInProgress changes", async () => {
describe("RULE: Only the refresh action locks while a query is in progress", () => {
test("EXAMPLE: The refresh button loads and disables, the auto-refresh selector stays usable", async () => {
const { setQueryInProgress, verify } = renderRefreshConfig(false);

verify.refreshButtonIsNotLoading();
Expand All @@ -78,7 +78,8 @@ describe("FEATURE: Refresh Controls Query State", () => {

verify.refreshButtonIsLoading();
verify.refreshButtonIsDisabled();
verify.autoRefreshSelectorIsDisabled();
// The selector must stay usable so auto-refresh can be turned off during a slow query
verify.autoRefreshSelectorIsEnabled();

await setQueryInProgress(false);

Expand Down
2 changes: 1 addition & 1 deletion src/Frontend/src/components/RefreshConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async function refresh() {
<div class="filter">
<div class="filter-label">Auto-Refresh:</div>
<div class="filter-component">
<ListFilterSelector :items="autoRefreshOptionsText.map((i) => i[1])" v-model="selectedRefresh" item-name="result" :can-clear="false" :show-clear="false" :show-filter="false" :disabled="props.queryInProgress" />
<ListFilterSelector :items="autoRefreshOptionsText.map((i) => i[1])" v-model="selectedRefresh" item-name="result" :can-clear="false" :show-clear="false" :show-filter="false" />
</div>
</div>
</div>
Expand Down
123 changes: 95 additions & 28 deletions src/Frontend/src/components/audit/AuditList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,16 @@ interface QueryStateAssertions {
messagesAreNotVisible(): void;
refreshControlsKnowQueryIsInProgress(): void;
refreshControlsKnowQueryIsIdle(): void;
filtersKnowQueryIsInProgress(): void;
filtersKnowQueryIsIdle(): void;
filtersAreNotBlockedByQuery(): void;
}

interface RenderResult {
verify: QueryStateAssertions;
isRefreshing: Ref<boolean>;
refreshNow: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
store: ReturnType<typeof useAuditStore>;
unmount: () => void;
}

// ==================== DOM Query Helpers ====================
Expand Down Expand Up @@ -105,17 +106,22 @@ function createMessage(id = "msg-1"): Message {

// ==================== Component Renderer ====================

async function renderAuditList(messages: Message[] = []): Promise<RenderResult> {
async function renderAuditList(messages: Message[] = [], options: { neverCompleteFirstQuery?: boolean } = {}): Promise<RenderResult> {
const isRefreshing = ref(false);
const refreshNow = vi.fn().mockResolvedValue(undefined);
if (options.neverCompleteFirstQuery) {
refreshNow.mockImplementationOnce(() => new Promise(() => {}));
}
const stop = vi.fn();

vi.mocked(useFetchWithAutoRefresh).mockReturnValue({
refreshNow,
isRefreshing: shallowReadonly(isRefreshing),
updateInterval: vi.fn(),
isActive: ref(false),
start: vi.fn(),
stop: vi.fn(),
stop,
nextRefreshAt: shallowReadonly(ref<number | null>(null)),
});

const router = createRouter({
Expand All @@ -133,7 +139,7 @@ async function renderAuditList(messages: Message[] = []): Promise<RenderResult>
},
});

render(AuditList, {
const { unmount } = render(AuditList, {
global: {
plugins: [pinia, router],
stubs: {
Expand Down Expand Up @@ -172,15 +178,14 @@ async function renderAuditList(messages: Message[] = []): Promise<RenderResult>
refreshControlsKnowQueryIsIdle() {
expect(getRefreshConfig().dataset.queryInProgress).toBe("false");
},
filtersKnowQueryIsInProgress() {
expect(getFiltersPanel().dataset.queryInProgress).toBe("true");
},
filtersKnowQueryIsIdle() {
expect(getFiltersPanel().dataset.queryInProgress).toBe("false");
filtersAreNotBlockedByQuery() {
// The filters panel is deliberately not told about query progress: entering a new
// query must always be possible, even while a slow query is still running.
expect(getFiltersPanel().dataset.queryInProgress).toBe("undefined");
},
};

return { verify, isRefreshing, refreshNow, store: useAuditStore(pinia) };
return { verify, isRefreshing, refreshNow, stop, store: useAuditStore(pinia), unmount };
}

// A control change reaches the fetch via two async hops (controls watcher -> router.push -> route watcher),
Expand Down Expand Up @@ -213,7 +218,7 @@ describe("FEATURE: Audit Messages Query State", () => {
verify.spinnerIsVisible();
verify.messagesAreNotVisible();
verify.refreshControlsKnowQueryIsInProgress();
verify.filtersKnowQueryIsInProgress();
verify.filtersAreNotBlockedByQuery();
});

test("EXAMPLE: Spinner is hidden after the first fetch completes", async () => {
Expand All @@ -224,7 +229,7 @@ describe("FEATURE: Audit Messages Query State", () => {
await waitFor(() => verify.spinnerIsNotVisible());
verify.overlayIsNotVisible();
verify.refreshControlsKnowQueryIsIdle();
verify.filtersKnowQueryIsIdle();
verify.filtersAreNotBlockedByQuery();
});
});

Expand Down Expand Up @@ -256,8 +261,8 @@ describe("FEATURE: Audit Messages Query State", () => {
});
});

describe("RULE: Query controls are disabled during a fetch", () => {
test("EXAMPLE: Query controls are disabled when a re-fetch is in-flight", async () => {
describe("RULE: Filters stay usable while a query is running", () => {
test("EXAMPLE: The filters are not blocked when a re-fetch is in-flight", async () => {
const { verify, isRefreshing } = await renderAuditList([]);

await waitForFirstLoadToComplete();
Expand All @@ -266,10 +271,10 @@ describe("FEATURE: Audit Messages Query State", () => {
await nextTick();

verify.refreshControlsKnowQueryIsInProgress();
verify.filtersKnowQueryIsInProgress();
verify.filtersAreNotBlockedByQuery();
});

test("EXAMPLE: Query controls are re-enabled after the fetch completes", async () => {
test("EXAMPLE: The refresh action is re-enabled after the fetch completes", async () => {
const { verify, isRefreshing } = await renderAuditList([]);

await waitForFirstLoadToComplete();
Expand All @@ -281,36 +286,60 @@ describe("FEATURE: Audit Messages Query State", () => {
await nextTick();

verify.refreshControlsKnowQueryIsIdle();
verify.filtersKnowQueryIsIdle();
verify.filtersAreNotBlockedByQuery();
});
});

describe("RULE: A loading overlay is shown when re-fetching with existing results", () => {
test("EXAMPLE: Overlay appears over existing messages while a re-fetch is in-flight", async () => {
describe("RULE: Existing results stay visible and usable while a re-fetch runs", () => {
// The refresh button already signals the running query (spinner, Cancel, elapsed time).
// Covering the stale rows would only take them away from the user while waiting.
test("EXAMPLE: No overlay covers the rows during a re-fetch, however long it takes", async () => {
const { verify, isRefreshing } = await renderAuditList([createMessage()]);

await waitForFirstLoadToComplete();
await waitFor(() => verify.messagesAreVisible());

isRefreshing.value = true;
await nextTick();
verify.overlayIsNotVisible();
verify.spinnerIsNotVisible();

verify.overlayIsVisible();
await new Promise((r) => setTimeout(r, 400));
verify.overlayIsNotVisible();
verify.messagesAreVisible();
});
});

test("EXAMPLE: Overlay disappears after the re-fetch completes", async () => {
const { verify, isRefreshing } = await renderAuditList([createMessage()]);
describe("RULE: A failed query tells the user what happened and what to try", () => {
test("EXAMPLE: The error banner is shown after a failed query", async () => {
const { store } = await renderAuditList([]);

await waitForFirstLoadToComplete();

isRefreshing.value = true;
store.queryFailed = true;
await nextTick();
verify.overlayIsVisible();

isRefreshing.value = false;
expect(screen.getByTestId("query-error")).toBeInTheDocument();
});

test("EXAMPLE: The error banner is not shown while a retry is in flight", async () => {
const { store, isRefreshing } = await renderAuditList([]);

await waitForFirstLoadToComplete();

store.queryFailed = true;
isRefreshing.value = true;
await nextTick();
verify.overlayIsNotVisible();

expect(screen.queryByTestId("query-error")).not.toBeInTheDocument();
});

test("EXAMPLE: The error banner is not shown when queries succeed", async () => {
const { verify } = await renderAuditList([createMessage()]);

await waitForFirstLoadToComplete();

verify.messagesAreVisible();
expect(screen.queryByTestId("query-error")).not.toBeInTheDocument();
});
});

Expand All @@ -327,6 +356,19 @@ describe("FEATURE: Audit Messages Query State", () => {
expect(refreshNow.mock.calls.length - queriesAfterFirstLoad).toBe(1);
});

test("EXAMPLE: Typing a search during the slow initial query still starts the new query", async () => {
const { refreshNow, store } = await renderAuditList([], { neverCompleteFirstQuery: true });

await waitForFirstLoadToComplete();
expect(refreshNow).toHaveBeenCalledTimes(1); // the initial query, still running

store.messageFilterString = "orders";
await waitForRouteDrivenQuery();

// The new query must not be swallowed just because the first one never finished
expect(refreshNow).toHaveBeenCalledTimes(2);
});

test("EXAMPLE: Changing the endpoint fires a single query", async () => {
const { refreshNow, store } = await renderAuditList([createMessage()]);

Expand All @@ -339,4 +381,29 @@ describe("FEATURE: Audit Messages Query State", () => {
expect(refreshNow.mock.calls.length - queriesAfterFirstLoad).toBe(1);
});
});

describe("RULE: Leaving the view stops its activity", () => {
test("EXAMPLE: Unmounting aborts the in-flight query and releases the auto-refresh", async () => {
const { stop, store, unmount } = await renderAuditList([createMessage()], { neverCompleteFirstQuery: true });

await waitForFirstLoadToComplete();

unmount();

expect(store.cancelQuery).toHaveBeenCalled();
expect(stop).toHaveBeenCalled();
});

test("EXAMPLE: Unmounting clears the results, so re-entering the view starts from a clean list", async () => {
// A refresh-in-place keeps stale rows on purpose; coming back to the view is not that:
// the query inputs may differ, so the previous rows must not be shown under the spinner
const { store, unmount } = await renderAuditList([createMessage()]);

await waitForFirstLoadToComplete();

unmount();

expect(store.clearResults).toHaveBeenCalled();
});
});
});
Loading