fix(frontend): add cooldown tooltips and fix settings tab initial load #50

Open
JustinZeus wants to merge 5 commits from fix/cooldown-tooltip-settings-load into main
5 changed files with 194 additions and 6 deletions
Showing only changes of commit 151e0774d8 - Show all commits

View file

@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ScrapeSafetyBadge from "./ScrapeSafetyBadge.vue";
import { createDefaultSafetyState, type ScrapeSafetyState } from "@/features/safety";
function buildState(overrides: Partial<ScrapeSafetyState> = {}): ScrapeSafetyState {
return { ...createDefaultSafetyState(), ...overrides };
}
describe("ScrapeSafetyBadge", () => {
it("shows ready tooltip when cooldown is inactive", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: { state: buildState({ cooldown_active: false }) },
});
expect(wrapper.text()).toContain("Safety ready");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
expect(hint.props("text")).toContain("No active cooldown");
});
it("shows cooldown tooltip with reason and action when active", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "blocked_failure_threshold_exceeded",
cooldown_reason_label: "Too many blocked requests",
recommended_action: "Wait for cooldown to expire",
cooldown_remaining_seconds: 120,
}),
},
});
expect(wrapper.text()).toContain("Safety cooldown");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).toContain("Too many blocked requests");
expect(text).toContain("Wait for cooldown to expire");
});
it("shows cooldown tooltip without optional fields", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "some_reason",
cooldown_reason_label: null,
recommended_action: null,
cooldown_remaining_seconds: 60,
}),
},
});
const hint = wrapper.findComponent({ name: "AppHelpHint" });
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).not.toContain("Why:");
expect(text).not.toContain("Action:");
});
});

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { type ScrapeSafetyState } from "@/features/safety"; import { type ScrapeSafetyState } from "@/features/safety";
const props = defineProps<{ const props = defineProps<{
@ -18,10 +19,30 @@ const toneClass = computed(() => {
} }
return "border-state-warning-border bg-state-warning-bg text-state-warning-text"; return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
}); });
const READY_TOOLTIP = "No active cooldown. Scraping can proceed normally.";
const RATE_LIMIT_INTRO =
"Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
const tooltipText = computed(() => {
if (!props.state.cooldown_active) return READY_TOOLTIP;
const parts = [RATE_LIMIT_INTRO];
if (props.state.cooldown_reason_label) {
parts.push(`Why: ${props.state.cooldown_reason_label}`);
}
if (props.state.recommended_action) {
parts.push(`Action: ${props.state.recommended_action}`);
}
return parts.join(" \u2014 ");
});
</script> </script>
<template> <template>
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass"> <span class="inline-flex items-center gap-1">
{{ label }} <span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
{{ label }}
</span>
<AppHelpHint :text="tooltipText" />
</span> </span>
</template> </template>

View file

@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AppAlert from "@/components/ui/AppAlert.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { import {
formatCooldownCountdown, formatCooldownCountdown,
type ScrapeSafetyState, type ScrapeSafetyState,
@ -79,6 +80,10 @@ const actionText = computed(() => {
return props.safetyState.recommended_action; return props.safetyState.recommended_action;
}); });
const bannerHintText = computed(() => {
return "Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
});
onMounted(() => { onMounted(() => {
timer = setInterval(() => { timer = setInterval(() => {
now.value = Date.now(); now.value = Date.now();
@ -95,7 +100,12 @@ onBeforeUnmount(() => {
<template> <template>
<AppAlert v-if="isVisible" :tone="tone"> <AppAlert v-if="isVisible" :tone="tone">
<template #title>{{ title }}</template> <template #title>
<span class="inline-flex items-center gap-1">
{{ title }}
<AppHelpHint :text="bannerHintText" />
</span>
</template>
<p>{{ detailText }}</p> <p>{{ detailText }}</p>
<p v-if="actionText" class="text-secondary">{{ actionText }}</p> <p v-if="actionText" class="text-secondary">{{ actionText }}</p>
</AppAlert> </AppAlert>

View file

@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import SettingsAdminPanel from "./SettingsAdminPanel.vue";
vi.mock("@/features/admin_users", () => ({
listAdminUsers: vi.fn().mockResolvedValue([]),
createAdminUser: vi.fn(),
setAdminUserActive: vi.fn(),
resetAdminUserPassword: vi.fn(),
}));
vi.mock("@/features/admin_dbops", () => ({
getAdminDbIntegrityReport: vi.fn().mockResolvedValue({
status: "ok",
warnings: [],
failures: [],
checked_at: null,
checks: [],
}),
listAdminPdfQueue: vi.fn().mockResolvedValue({
items: [],
total_count: 0,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
}),
requeueAdminPdfLookup: vi.fn(),
requeueAllAdminPdfLookups: vi.fn(),
}));
vi.mock("@/stores/auth", () => ({
useAuthStore: () => ({ isAdmin: true }),
}));
vi.mock("@/features/admin_repairs", () => ({
listAdminRepairTasks: vi.fn().mockResolvedValue([]),
runAdminRepairTask: vi.fn(),
}));
import { listAdminUsers } from "@/features/admin_users";
import { getAdminDbIntegrityReport, listAdminPdfQueue } from "@/features/admin_dbops";
const mockedListUsers = vi.mocked(listAdminUsers);
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
const mockedListPdfQueue = vi.mocked(listAdminPdfQueue);
describe("SettingsAdminPanel", () => {
beforeEach(() => {
mockedListUsers.mockClear();
mockedGetReport.mockClear();
mockedListPdfQueue.mockClear();
});
it("calls load on users section after nextTick when section=users", async () => {
mount(SettingsAdminPanel, { props: { section: "users" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedListUsers).toHaveBeenCalled();
});
it("calls load on integrity section after nextTick when section=integrity", async () => {
mount(SettingsAdminPanel, { props: { section: "integrity" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("calls load on new section when section prop changes", async () => {
const wrapper = mount(SettingsAdminPanel, { props: { section: "users" } });
await nextTick();
await nextTick();
await flushPromises();
mockedListUsers.mockClear();
mockedGetReport.mockClear();
await wrapper.setProps({ section: "integrity" });
await nextTick();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("calls load on pdf section when section=pdf", async () => {
mount(SettingsAdminPanel, { props: { section: "pdf" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedListPdfQueue).toHaveBeenCalled();
});
});

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from "vue"; import { nextTick, onMounted, watch } from "vue";
import { ref } from "vue"; import { ref } from "vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
@ -43,8 +43,10 @@ async function loadSection(): Promise<void> {
} }
} }
onMounted(loadSection); onMounted(() => {
watch(() => props.section, loadSection); nextTick(loadSection);
});
watch(() => props.section, loadSection, { flush: "post" });
</script> </script>
<template> <template>