feat(32-01): create OnboardingSummaryStep component and tests

- Read-only summary card with hardware, mode, provider, root dir rows
- SummaryRow helper component with optional mono styling
- Start chatting CTA with spinner and disabled state
- 6 unit tests covering rendering, empty root dir, error, click, loading
This commit is contained in:
Nexus Dev 2026-04-03 21:34:32 +00:00
parent 2355dac4bd
commit c0d7ea5a3c
2 changed files with 228 additions and 0 deletions

View file

@ -0,0 +1,99 @@
// @vitest-environment jsdom
// [nexus] Unit tests for OnboardingSummaryStep component
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { OnboardingSummaryStep } from "./OnboardingSummaryStep";
import type { HardwareInfo } from "@/api/hardware";
afterEach(() => {
cleanup();
});
const mockHardwareInfo: HardwareInfo = {
totalGb: 16,
freeGb: 8,
usableGb: 12,
platform: "darwin",
gpuName: null,
gpuVramGb: null,
unifiedMemory: true,
hardwareTier: "apple_silicon",
cpuModel: "Apple M2",
};
const defaultProps = {
hardwareInfo: mockHardwareInfo,
selectedMode: "both" as const,
providerLabel: "Puter (free, zero-config)",
rootDir: "~/projects/test",
loading: false,
error: null,
onStartChat: vi.fn(),
onBack: vi.fn(),
};
describe("OnboardingSummaryStep", () => {
it("renders all summary rows with provided data", () => {
const html = renderToStaticMarkup(
<OnboardingSummaryStep {...defaultProps} />
);
expect(html).toContain("Hardware");
expect(html).toContain("Apple Silicon");
expect(html).toContain("Mode");
expect(html).toContain("Both (recommended)");
expect(html).toContain("Provider");
expect(html).toContain("Puter (free, zero-config)");
expect(html).toContain("Root directory");
expect(html).toContain("~/projects/test");
});
it("hides root directory row when rootDir is empty string", () => {
const html = renderToStaticMarkup(
<OnboardingSummaryStep {...defaultProps} rootDir="" />
);
expect(html).not.toContain("Root directory");
});
it("shows error message when error prop is non-null", () => {
const html = renderToStaticMarkup(
<OnboardingSummaryStep
{...defaultProps}
error="Setup failed. Please try again."
/>
);
expect(html).toContain("Setup failed. Please try again.");
});
it("calls onStartChat when 'Start chatting' button is clicked", () => {
const onStartChat = vi.fn();
render(<OnboardingSummaryStep {...defaultProps} onStartChat={onStartChat} />);
const buttons = screen.getAllByRole("button");
const startBtn = buttons.find((b) => b.textContent?.includes("Start chatting"));
expect(startBtn).toBeTruthy();
fireEvent.click(startBtn!);
expect(onStartChat).toHaveBeenCalledOnce();
});
it("disables 'Start chatting' button when loading is true", () => {
render(<OnboardingSummaryStep {...defaultProps} loading={true} />);
const buttons = screen.getAllByRole("button");
const startBtn = buttons.find((b) => b.textContent?.includes("Setting up"));
expect(startBtn).toBeTruthy();
expect(startBtn).toHaveProperty("disabled", true);
});
it("shows 'Setting up...' text when loading is true", () => {
const html = renderToStaticMarkup(
<OnboardingSummaryStep {...defaultProps} loading={true} />
);
expect(html).toContain("Setting up...");
});
});

View file

@ -0,0 +1,129 @@
// [nexus] Summary screen for onboarding wizard step 5 — read-only review before starting
import type { HardwareInfo, HardwareTier, NexusMode } from "@/api/hardware";
import { Button } from "@/components/ui/button";
interface OnboardingSummaryStepProps {
hardwareInfo: HardwareInfo | undefined;
selectedMode: NexusMode;
providerLabel: string;
rootDir: string;
loading: boolean;
error: string | null;
onStartChat: () => void;
onBack: () => void;
}
interface SummaryRowProps {
label: string;
value: string;
mono?: boolean;
}
function SummaryRow({ label, value, mono }: SummaryRowProps) {
return (
<div className="flex items-start justify-between gap-4">
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
<span className={mono ? "font-mono text-sm text-right" : "text-sm text-right"}>{value}</span>
</div>
);
}
const HARDWARE_TIER_LABELS: Record<HardwareTier, string> = {
gpu: "GPU",
apple_silicon: "Apple Silicon",
cpu_only: "CPU Only",
};
const MODE_LABELS: Record<NexusMode, string> = {
personal_ai: "Personal AI Assistant",
project_builder: "Project Builder",
both: "Both (recommended)",
};
export function OnboardingSummaryStep({
hardwareInfo,
selectedMode,
providerLabel,
rootDir,
loading,
error,
onStartChat,
onBack,
}: OnboardingSummaryStepProps) {
const hardwareLabel = hardwareInfo
? (HARDWARE_TIER_LABELS[hardwareInfo.hardwareTier] ?? "Unknown")
: "Unknown";
const modeLabel = MODE_LABELS[selectedMode];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Ready to go</h1>
<p className="text-sm text-muted-foreground">Review your setup before starting.</p>
</div>
{/* Summary card */}
<div className="rounded-lg border border-border p-4 flex flex-col gap-3">
<SummaryRow label="Hardware" value={hardwareLabel} />
<SummaryRow label="Mode" value={modeLabel} />
<SummaryRow label="Provider" value={providerLabel} />
{rootDir && <SummaryRow label="Root directory" value={rootDir} mono />}
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</p>
)}
{/* Actions */}
<div className="flex flex-col gap-2">
<Button
type="button"
onClick={onStartChat}
disabled={loading}
className="w-full"
>
{loading ? (
<span className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Setting up...
</span>
) : (
"Start chatting"
)}
</Button>
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={loading}
className="w-full"
>
Back
</Button>
</div>
</div>
);
}