Two visit steps join the getting-started path: "Browse categories and keywords" (5th) and "Browse the reports" (8th). Nothing in the database counts them: they are done by "Got it" on their card, stored as onboarding_acknowledged_<key> in user_preferences (no migration). - computeChecklist: eight rows; the visit rows are locked until the profile's visit steps are initialized, so a finished path does not flash a "Next step" before they are marked done. - initializeVisitSteps: once per profile, after the first successful load with the Budget entitlement known; acknowledges both steps when every other step is done or skipped, then sets the flag. No transaction (D16): idempotent writes, the flag last. - Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps the card latch, so the card says "Done." and offers the next step. - StepCard: "Got it" before "Skip this step" when onAcknowledge is given; OnboardingStepCard passes it for a visit step. - Banner counter, card rank and end of path wait for budgetReady && visitStepsInitialized; the list shows the visit rows "pending" until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
// AppShell and the getting-started banner (#397). No jsdom: react-dom/server
|
|
// renders in vitest's node environment. The path comes from a fake
|
|
// `useOnboarding()` built like the provider builds it: a loaded state, the
|
|
// real `computeChecklist`, and the provider's own pure derivation
|
|
// (`deriveOnboardingView`, with the real `CARD_PAGES`) at the rendered route.
|
|
// What is pinned is where the banner shows, and where it sits.
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import type { ReactNode } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|
|
|
// Importing the hook module pulls the onboarding service (→ db → tauri
|
|
// plugins) and useEntitlement (→ LicenseContext): stub both.
|
|
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
|
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
|
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
|
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
|
|
|
|
let onboarding: OnboardingValue;
|
|
vi.mock("../../contexts/OnboardingContext", () => ({
|
|
OnboardingProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
useOnboarding: () => onboarding,
|
|
}));
|
|
|
|
import AppShell from "./AppShell";
|
|
import {
|
|
deriveOnboardingView,
|
|
initialOnboardingChecklistState,
|
|
onboardingChecklistReducer,
|
|
type OnboardingChecklistState,
|
|
type OnboardingValue,
|
|
} from "../../hooks/useOnboardingChecklist";
|
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
|
import { CARD_PAGES } from "../../utils/onboardingPath";
|
|
import type { OnboardingSnapshot } from "../../services/onboardingService";
|
|
|
|
const NEW_PROFILE: OnboardingSnapshot = {
|
|
counters: { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
|
importFolderSet: false,
|
|
hidden: false,
|
|
skipped: [],
|
|
acknowledged: [],
|
|
visitStepsInitialized: true,
|
|
};
|
|
/**
|
|
* Folder, source and import done: the next step is the manual category, on
|
|
* /transactions. Stopped after the import, so the visit steps are
|
|
* acknowledged (rule of the templates, #425).
|
|
*/
|
|
const IMPORTED: OnboardingSnapshot = {
|
|
...NEW_PROFILE,
|
|
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
|
|
importFolderSet: true,
|
|
acknowledged: ["categories", "reports"],
|
|
};
|
|
/** Everything but the budget: the next step is on /budget. */
|
|
const BUDGET_LEFT: OnboardingSnapshot = {
|
|
...IMPORTED,
|
|
counters: { ...IMPORTED.counters, manualCategorized: 1 },
|
|
};
|
|
const ALL_DONE: OnboardingSnapshot = {
|
|
...IMPORTED,
|
|
counters: { ...IMPORTED.counters, manualCategorized: 1, budgetEntries: 1 },
|
|
};
|
|
|
|
/** The provider's state after a first successful load of `snapshot`. */
|
|
const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
|
|
[
|
|
{ type: "LOAD_START", seq: 1 } as const,
|
|
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
|
|
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
|
|
|
|
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
|
|
function pathAt(
|
|
pathname: string,
|
|
state: OnboardingChecklistState,
|
|
budgetReady = true,
|
|
budgetAllowed = true,
|
|
): OnboardingValue {
|
|
const checklist = state.counters
|
|
? computeChecklist(state.counters, {
|
|
importFolderSet: state.importFolderSet,
|
|
budgetAllowed,
|
|
skipped: state.skipped,
|
|
acknowledged: state.acknowledged,
|
|
visitStepsInitialized: state.visitStepsInitialized,
|
|
})
|
|
: null;
|
|
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
|
|
return {
|
|
status: state.status,
|
|
checklist,
|
|
counters: state.counters,
|
|
hidden: state.hidden,
|
|
budgetReady,
|
|
visitStepsInitialized: state.visitStepsInitialized,
|
|
nextStep,
|
|
mode,
|
|
cardPage,
|
|
hide: vi.fn(async () => undefined),
|
|
show: vi.fn(async () => undefined),
|
|
skip: vi.fn(async () => undefined),
|
|
resume: vi.fn(async () => undefined),
|
|
acknowledge: vi.fn(async () => undefined),
|
|
refresh: vi.fn(async () => undefined),
|
|
};
|
|
}
|
|
|
|
function renderWith(pathname: string, value: OnboardingValue): string {
|
|
onboarding = value;
|
|
return renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={[pathname]}>
|
|
<Routes>
|
|
<Route element={<AppShell />}>
|
|
<Route path="*" element={<p data-page="">page</p>} />
|
|
</Route>
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
const renderAt = (pathname: string, state: OnboardingChecklistState, budgetReady = true) =>
|
|
renderWith(pathname, pathAt(pathname, state, budgetReady));
|
|
|
|
const BANNER = 'aria-label="t(onboarding.banner.label)"';
|
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
|
|
|
describe("AppShell — the getting-started banner (#397)", () => {
|
|
it("puts the banner on top of the routed page, inside main", () => {
|
|
const html = renderAt("/reports", loaded(IMPORTED));
|
|
expect(count(html, BANNER)).toBe(1);
|
|
const main = html.indexOf("<main");
|
|
const banner = html.indexOf(BANNER);
|
|
expect(main).toBeGreaterThan(-1);
|
|
expect(banner).toBeGreaterThan(main);
|
|
expect(html.indexOf("data-page")).toBeGreaterThan(banner);
|
|
});
|
|
|
|
it("shows nothing before the first load", () => {
|
|
const html = renderAt("/import", initialOnboardingChecklistState);
|
|
expect(html).not.toContain(BANNER);
|
|
expect(html).toContain("data-page");
|
|
});
|
|
|
|
it("shows nothing when the path is hidden", () => {
|
|
const html = renderAt("/import", loaded({ ...IMPORTED, hidden: true }));
|
|
expect(html).not.toContain(BANNER);
|
|
expect(html).toContain("data-page");
|
|
});
|
|
|
|
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
|
|
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
|
|
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain(BANNER);
|
|
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
|
|
});
|
|
|
|
it("shows the banner on a dashboard with transactions, and on any other page", () => {
|
|
for (const pathname of ["/", "/reports", "/categories", "/settings/users"]) {
|
|
expect(renderAt(pathname, loaded(IMPORTED)), pathname).toContain(BANNER);
|
|
}
|
|
});
|
|
|
|
it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => {
|
|
expect(CARD_PAGES.has("/import")).toBe(true);
|
|
const value = pathAt("/import", loaded(NEW_PROFILE));
|
|
expect(value.nextStep?.to).toBe("/import");
|
|
expect(value.mode).toBe("card");
|
|
expect(value.cardPage).toBe("/import");
|
|
expect(renderWith("/import", value)).not.toContain(BANNER);
|
|
// Anywhere else, the same profile keeps the banner and its link to /import.
|
|
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"');
|
|
});
|
|
|
|
it("gives /transactions, the page of the next step, its step card instead of the banner (#399)", () => {
|
|
expect(CARD_PAGES.has("/transactions")).toBe(true);
|
|
const value = pathAt("/transactions", loaded(IMPORTED));
|
|
expect(value.nextStep?.to).toBe("/transactions");
|
|
expect(value.mode).toBe("card");
|
|
expect(value.cardPage).toBe("/transactions");
|
|
expect(renderWith("/transactions", value)).not.toContain(BANNER);
|
|
// Anywhere else, the same profile keeps the banner and its link to /transactions.
|
|
expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"');
|
|
});
|
|
|
|
it("gives /budget, the page of the next step, its step card instead of the banner (#400)", () => {
|
|
expect(CARD_PAGES.has("/budget")).toBe(true);
|
|
const value = pathAt("/budget", loaded(BUDGET_LEFT));
|
|
expect(value.nextStep?.to).toBe("/budget");
|
|
expect(value.mode).toBe("card");
|
|
expect(value.cardPage).toBe("/budget");
|
|
expect(renderWith("/budget", value)).not.toContain(BANNER);
|
|
// Anywhere else, the same profile keeps the banner and its link to /budget.
|
|
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
|
|
});
|
|
|
|
it("in the Free edition, /budget never gets a card and the path ends at 7/7 (#400)", () => {
|
|
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
|
|
expect(value.checklist?.total).toBe(7);
|
|
expect(value.nextStep).toBeNull();
|
|
expect(value.cardPage).toBeNull();
|
|
expect(value.mode).toBe("complete");
|
|
const html = renderWith("/budget", value);
|
|
expect(html).toContain(BANNER);
|
|
expect(html).toContain("t(onboarding.banner.complete)");
|
|
});
|
|
|
|
it("shows nothing on a page that carries a step card", () => {
|
|
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
|
|
expect(renderWith("/import", value)).not.toContain(BANNER);
|
|
});
|
|
|
|
it("shows the end message once every step is done (D4)", () => {
|
|
const html = renderAt("/reports", loaded(ALL_DONE));
|
|
expect(html).toContain(BANNER);
|
|
expect(html).toContain("t(onboarding.banner.complete)");
|
|
});
|
|
|
|
it("waits for the Budget entitlement before showing the counter", () => {
|
|
const waiting = renderAt("/reports", loaded(IMPORTED), false);
|
|
expect(waiting).toContain(BANNER);
|
|
expect(waiting).not.toContain("onboarding.banner.progress");
|
|
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
|
|
"t(onboarding.banner.progress[done=6,total=8])",
|
|
);
|
|
});
|
|
|
|
it("before the visit steps' initialization: no counter, and no end for a path that was complete (#425)", () => {
|
|
const waiting = renderAt("/reports", loaded({ ...IMPORTED, acknowledged: [], visitStepsInitialized: false }));
|
|
expect(waiting).toContain(BANNER);
|
|
expect(waiting).not.toContain("onboarding.banner.progress");
|
|
const complete = renderAt("/reports", loaded({ ...ALL_DONE, acknowledged: [], visitStepsInitialized: false }));
|
|
expect(complete).not.toContain(BANNER);
|
|
expect(complete).not.toContain("onboarding.banner.next");
|
|
});
|
|
|
|
it("starts with the list folded", () => {
|
|
const html = renderAt("/reports", loaded(IMPORTED));
|
|
expect(html).toContain('aria-expanded="false"');
|
|
expect(html).not.toContain("onboarding.checklist.title");
|
|
});
|
|
});
|