establish db
This commit is contained in:
125
apps/web/src/app/api/diseases/diseases-api.test.ts
Normal file
125
apps/web/src/app/api/diseases/diseases-api.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GET } from "./route";
|
||||
import * as diseasesLib from "@/lib/api/diseases";
|
||||
|
||||
// Mock the diseases library
|
||||
vi.mock("@/lib/api/diseases", () => ({
|
||||
listDiseases: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("GET /api/diseases", () => {
|
||||
const createRequest = (searchParams: string) => {
|
||||
const url = new URL(`http://localhost/api/diseases${searchParams}`);
|
||||
const req = new Request(url);
|
||||
// Mock NextRequest.nextUrl
|
||||
(req as any).nextUrl = url;
|
||||
return req;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns all diseases with no filters", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "early-blight", name: "Early Blight" },
|
||||
{ id: "late-blight", name: "Late Blight" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest(""));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.diseases).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
it("filters diseases by plantId", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "early-blight", name: "Early Blight", plantId: "tomato" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?plantId=tomato"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("filters diseases by search term", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "early-blight", name: "Early Blight" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?search=blight"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("filters diseases by causalAgentType", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "early-blight", name: "Early Blight", causalAgentType: "fungal" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?causalAgentType=fungal"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("filters diseases by severity", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "early-blight", name: "Early Blight", severity: "moderate" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?severity=moderate"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 for empty search term", async () => {
|
||||
const response = await GET(createRequest("?search="));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Bad Request");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid causalAgentType", async () => {
|
||||
const response = await GET(createRequest("?causalAgentType=invalid"));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toMatch(/Invalid causalAgentType/i);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid severity", async () => {
|
||||
const response = await GET(createRequest("?severity=invalid"));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toMatch(/Invalid severity/i);
|
||||
});
|
||||
|
||||
it("accepts valid causalAgentTypes", async () => {
|
||||
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
|
||||
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
|
||||
for (const type of validTypes) {
|
||||
const response = await GET(createRequest(`?causalAgentType=${type}`));
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid severities", async () => {
|
||||
const validSeverities = ["low", "moderate", "high", "critical"];
|
||||
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
|
||||
for (const severity of validSeverities) {
|
||||
const response = await GET(createRequest(`?severity=${severity}`));
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns cache control header", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
const response = await GET(createRequest(""));
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
expect(cacheControl).toContain("max-age=3600");
|
||||
});
|
||||
});
|
||||
27
apps/web/src/app/api/health/health.test.ts
Normal file
27
apps/web/src/app/api/health/health.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("GET /api/health", () => {
|
||||
it("returns 200 with status ok", async () => {
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns valid ISO timestamp", async () => {
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
const date = new Date(body.timestamp);
|
||||
expect(date.toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
it("returns JSON content type", async () => {
|
||||
const response = await GET();
|
||||
const contentType = response.headers.get("content-type");
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
});
|
||||
95
apps/web/src/app/api/plants/plants.test.ts
Normal file
95
apps/web/src/app/api/plants/plants.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GET } from "./route";
|
||||
import * as diseasesLib from "@/lib/api/diseases";
|
||||
|
||||
// Mock the diseases library
|
||||
vi.mock("@/lib/api/diseases", () => ({
|
||||
listPlants: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("GET /api/plants", () => {
|
||||
const createRequest = (searchParams: string) => {
|
||||
const url = new URL(`http://localhost/api/plants${searchParams}`);
|
||||
const req = new Request(url);
|
||||
(req as any).nextUrl = url;
|
||||
return req;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns all plants with no filters", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "tomato", commonName: "Tomato" },
|
||||
{ id: "pepper", commonName: "Pepper" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest(""));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.plants).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
it("filters plants by search term", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "tomato", commonName: "Tomato" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?search=tomato"));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.plants[0].commonName).toBe("Tomato");
|
||||
});
|
||||
|
||||
it("filters plants by category", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "tomato", commonName: "Tomato", category: "vegetables" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?category=vegetables"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 for empty search term", async () => {
|
||||
const response = await GET(createRequest("?search="));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Bad Request");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid category", async () => {
|
||||
const response = await GET(createRequest("?category=invalid"));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toMatch(/Invalid category/i);
|
||||
});
|
||||
|
||||
it("returns cache control header", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
const response = await GET(createRequest(""));
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
expect(cacheControl).toContain("max-age=3600");
|
||||
});
|
||||
|
||||
it("accepts valid categories", async () => {
|
||||
const validCategories = [
|
||||
"vegetables",
|
||||
"herbs",
|
||||
"houseplants",
|
||||
"flowers",
|
||||
];
|
||||
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
|
||||
for (const cat of validCategories) {
|
||||
const response = await GET(createRequest(`?category=${cat}`));
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
152
apps/web/src/app/browse/BrowseContent.test.tsx
Normal file
152
apps/web/src/app/browse/BrowseContent.test.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import BrowseContent from "@/app/browse/BrowseContent";
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: vi.fn(() => ({
|
||||
get: vi.fn(() => null),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock PlantCard
|
||||
vi.mock("@/components/PlantCard", () => ({
|
||||
default: ({ plant }: any) => (
|
||||
<div data-testid={`plant-card-${plant.id}`}>
|
||||
<span>{plant.commonName}</span>
|
||||
<span>{plant.emoji}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock EmptyState
|
||||
vi.mock("@/components/EmptyState", () => ({
|
||||
default: ({ title, description, actionLabel }: any) => (
|
||||
<div data-testid="empty-state">
|
||||
<span data-testid="empty-title">{title}</span>
|
||||
<span data-testid="empty-desc">{description}</span>
|
||||
{actionLabel && <span>{actionLabel}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("BrowseContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders page header with plant count", () => {
|
||||
render(<BrowseContent />);
|
||||
expect(screen.getByText("Browse Plants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders search input", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox", {
|
||||
name: /Search plants and diseases/i,
|
||||
});
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters plants by search query", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "tomato" } });
|
||||
|
||||
// Should show tomato plant
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows results count", () => {
|
||||
render(<BrowseContent />);
|
||||
expect(screen.getByText(/Showing \d+ plants/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders category filter tabs", () => {
|
||||
render(<BrowseContent />);
|
||||
const tablist = screen.getByRole("tablist", { name: /Plant categories/i });
|
||||
expect(tablist).toBeInTheDocument();
|
||||
|
||||
// Should have category tabs
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("filters by category when tab is clicked", () => {
|
||||
render(<BrowseContent />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
|
||||
// Click a category tab (not 'all')
|
||||
const vegTab = tabs.find((t) => t.textContent?.toLowerCase().includes("vegetable"));
|
||||
if (vegTab) {
|
||||
fireEvent.click(vegTab);
|
||||
expect(screen.getByText(/in vegetable/i)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears search when clear button is clicked", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "tomato" } });
|
||||
expect(searchInput.value).toBe("tomato");
|
||||
|
||||
const clearBtn = screen.getByRole("button", { name: /Clear search/i });
|
||||
fireEvent.click(clearBtn);
|
||||
|
||||
expect(searchInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("shows empty state when no plants match search", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
|
||||
|
||||
expect(screen.getByTestId("empty-title")).toHaveTextContent("No plants found");
|
||||
});
|
||||
|
||||
it("shows empty state with search query in description", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
|
||||
|
||||
expect(screen.getByTestId("empty-desc")).toHaveTextContent(/xyznonexistent123/i);
|
||||
});
|
||||
|
||||
it("shows matching text in results count", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "tomato" } });
|
||||
|
||||
expect(screen.getByText(/matching "tomato"/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all plant cards when no filter applied", () => {
|
||||
render(<BrowseContent />);
|
||||
// Should show all plants
|
||||
const plantCards = screen.getAllByTestId(/plant-card-/);
|
||||
expect(plantCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("searches by scientific name", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "solanum" } });
|
||||
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches by family name", () => {
|
||||
render(<BrowseContent />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "solanaceae" } });
|
||||
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
30
apps/web/src/app/not-found.test.tsx
Normal file
30
apps/web/src/app/not-found.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import NotFound from "@/app/not-found";
|
||||
|
||||
describe("NotFound (404 page)", () => {
|
||||
it("renders 404 heading", () => {
|
||||
render(<NotFound />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant-themed messaging", () => {
|
||||
render(<NotFound />);
|
||||
// Should have plant-themed content
|
||||
const container = screen.container;
|
||||
expect(container.textContent).toMatch(/plant|leaf|garden|grow/i);
|
||||
});
|
||||
|
||||
it("renders link to go home", () => {
|
||||
render(<NotFound />);
|
||||
const homeLink = screen.getByRole("link", { name: /home/i });
|
||||
expect(homeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("renders illustration or emoji", () => {
|
||||
render(<NotFound />);
|
||||
// Should have some visual element
|
||||
const container = screen.container;
|
||||
expect(container.textContent).toMatch(/[🍂🌿🌱🌻🍃]/);
|
||||
});
|
||||
});
|
||||
66
apps/web/src/app/page.test.tsx
Normal file
66
apps/web/src/app/page.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Page from "@/app/page";
|
||||
|
||||
// Mock components that are used in the homepage
|
||||
vi.mock("@/components/Navbar", () => ({
|
||||
default: () => <nav data-testid="navbar">Navbar</nav>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/Footer", () => ({
|
||||
default: () => <footer data-testid="footer">Footer</footer>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ImageUpload", () => ({
|
||||
default: () => <div data-testid="image-upload">Upload</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/PlantCard", () => ({
|
||||
default: ({ plant }: any) => (
|
||||
<div data-testid={`plant-card-${plant.id}`}>{plant.commonName}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Homepage (page.tsx)", () => {
|
||||
it("renders hero section with title", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image upload component", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByTestId("image-upload")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders trust signals section", () => {
|
||||
render(<Page />);
|
||||
// Trust signals should be present
|
||||
const trustSignals = screen.queryAllByText(/95/i);
|
||||
expect(trustSignals.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("renders how it works section", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByText(/How It Works/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders featured plants section", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByText(/Featured Plants/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders navbar", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByTestId("navbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders footer", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByTestId("footer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders beta disclaimer", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByText(/beta/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
99
apps/web/src/app/results/results-page.test.tsx
Normal file
99
apps/web/src/app/results/results-page.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import ResultsPage from "@/app/results/[imageId]/page";
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
})),
|
||||
useParams: vi.fn(() => ({ imageId: "test-image-123" })),
|
||||
}));
|
||||
|
||||
// Mock API
|
||||
vi.mock("@/lib/api/identify", () => ({
|
||||
identifyPlant: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ResultsDashboard
|
||||
vi.mock("@/components/ResultsDashboard", () => ({
|
||||
default: ({ loading, error, response }: any) => (
|
||||
<div data-testid="results-dashboard">
|
||||
{loading && <span>Loading...</span>}
|
||||
{error && <span>Error: {error}</span>}
|
||||
{response && <span>Results for {response.predictions?.length} predictions</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LoadingSkeleton
|
||||
vi.mock("@/components/LoadingSkeleton", () => ({
|
||||
default: () => <div data-testid="loading-skeleton">Loading...</div>,
|
||||
}));
|
||||
|
||||
// Mock EmptyState
|
||||
vi.mock("@/components/EmptyState", () => ({
|
||||
default: ({ title }: any) => <div data-testid="empty-state">{title}</div>,
|
||||
}));
|
||||
|
||||
describe("ResultsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders loading state initially", () => {
|
||||
const { identifyPlant } = require("@/lib/api/identify");
|
||||
// Make identifyPlant never resolve
|
||||
identifyPlant.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(<ResultsPage />);
|
||||
expect(screen.getByTestId("results-dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state when identification fails", async () => {
|
||||
const { identifyPlant } = require("@/lib/api/identify");
|
||||
identifyPlant.mockRejectedValue(new Error("Image not found"));
|
||||
|
||||
render(<ResultsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("results-dashboard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders results when identification succeeds", async () => {
|
||||
const { identifyPlant } = require("@/lib/api/identify");
|
||||
identifyPlant.mockResolvedValue({
|
||||
predictions: [
|
||||
{
|
||||
diseaseId: "early-blight",
|
||||
disease: {
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
severity: "moderate",
|
||||
symptoms: ["Dark spots"],
|
||||
treatment: ["Remove leaves"],
|
||||
lookalikeDiseaseIds: [],
|
||||
plantId: "tomato",
|
||||
},
|
||||
confidence: { raw: 0.85, adjusted: 0.82 },
|
||||
lookalikes: [],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
model: "mock-model",
|
||||
inferenceTimeMs: 150,
|
||||
imageId: "test-image-123",
|
||||
},
|
||||
});
|
||||
|
||||
render(<ResultsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("results-dashboard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
apps/web/src/components/EmptyState.test.tsx
Normal file
69
apps/web/src/components/EmptyState.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders title", () => {
|
||||
render(<EmptyState title="No Results" />);
|
||||
expect(screen.getByText("No Results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description", () => {
|
||||
render(
|
||||
<EmptyState
|
||||
title="No Results"
|
||||
description="Try adjusting your search terms."
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Try adjusting your search terms.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders CTA button with label", () => {
|
||||
const onAction = vi.fn();
|
||||
render(
|
||||
<EmptyState
|
||||
title="No Results"
|
||||
actionLabel="Clear Filters"
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /Clear Filters/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onAction when CTA button is clicked", () => {
|
||||
const onAction = vi.fn();
|
||||
render(
|
||||
<EmptyState
|
||||
title="No Results"
|
||||
actionLabel="Try Again"
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Try Again/i }));
|
||||
expect(onAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render CTA button when no actionLabel provided", () => {
|
||||
render(<EmptyState title="No Results" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders illustration emoji", () => {
|
||||
render(<EmptyState title="No Results" illustration="🔍" />);
|
||||
expect(screen.getByText("🔍")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders default illustration when none provided", () => {
|
||||
render(<EmptyState title="No Results" />);
|
||||
// Default illustration should be present
|
||||
const container = screen.container;
|
||||
expect(container.querySelector(".text-5xl")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with custom className", () => {
|
||||
render(<EmptyState title="No Results" className="custom-class" />);
|
||||
const container = screen.container;
|
||||
expect(container.querySelector(".custom-class")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
111
apps/web/src/components/ErrorBoundary.test.tsx
Normal file
111
apps/web/src/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
|
||||
// Component that throws on render
|
||||
function ThrowOnRender() {
|
||||
throw new Error("Boom!");
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it("renders children when no error occurs", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child">Hello World</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Something went wrong/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback UI when child throws", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/A leaf must have fallen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom fallback when provided", () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div data-testid="custom-fallback">Custom error</div>}>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId("custom-fallback")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Try again' button that resets state", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
const tryAgain = screen.getByText(/Try again/);
|
||||
expect(tryAgain).toBeInTheDocument();
|
||||
|
||||
// Clicking Try again resets the error state
|
||||
fireEvent.click(tryAgain);
|
||||
// After reset, the child will throw again, so fallback reappears
|
||||
// But the key is the button exists and is clickable
|
||||
expect(tryAgain).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows 'Go home' link", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
const goHome = screen.getByText(/Go home/);
|
||||
expect(goHome).toBeInTheDocument();
|
||||
expect(goHome.closest("a")).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("shows error details in development mode", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Error details \(dev only\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show error details in production mode", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Error details/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logs error to console", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
44
apps/web/src/components/Footer.test.tsx
Normal file
44
apps/web/src/components/Footer.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
describe("Footer", () => {
|
||||
it("renders footer element", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByRole("contentinfo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders app name", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText(/Plant Disease/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders navigation links", () => {
|
||||
render(<Footer />);
|
||||
// Should have links
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders copyright or year", () => {
|
||||
render(<Footer />);
|
||||
const container = screen.container;
|
||||
expect(container.textContent).toMatch(/\d{4}/);
|
||||
});
|
||||
|
||||
it("renders disclaimer text", () => {
|
||||
render(<Footer />);
|
||||
const container = screen.container;
|
||||
expect(container.textContent).toMatch(/beta|preview|accuracy|disclaimer/i);
|
||||
});
|
||||
|
||||
it("renders links section with nav links", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText(/Links/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders about section", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText(/About/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
193
apps/web/src/components/ImageUpload.test.tsx
Normal file
193
apps/web/src/components/ImageUpload.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import ImageUpload from "@/components/ImageUpload";
|
||||
import * as uploadApi from "@/lib/api/upload";
|
||||
import * as imageProcessing from "@/lib/image-processing";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/api/upload", () => ({
|
||||
uploadImage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/image-processing", () => ({
|
||||
validateImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ImageUpload", () => {
|
||||
const mockFile = new File(["dummy"], "test.png", { type: "image/png" });
|
||||
const mockResponse = {
|
||||
imageId: "test-id-123",
|
||||
tensorShape: [3, 224, 224],
|
||||
previewUrl: "/uploads/test-id-123.png",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
|
||||
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders drop zone with upload prompt", () => {
|
||||
render(<ImageUpload />);
|
||||
expect(screen.getByRole("button", { name: /Upload a plant image/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/PNG, JPG, WebP/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disabled state when disabled prop is true", () => {
|
||||
render(<ImageUpload disabled />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
expect(dropZone).toHaveClass("opacity-50");
|
||||
});
|
||||
|
||||
it("triggers file input click on drop zone click", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.click(dropZone);
|
||||
// The hidden file input exists in the DOM
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
expect(fileInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers file input on keyboard Enter", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.keyDown(dropZone, { key: "Enter", code: "Enter" });
|
||||
});
|
||||
|
||||
it("triggers file input on keyboard Space", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.keyDown(dropZone, { key: " ", code: "Space" });
|
||||
});
|
||||
|
||||
it("shows drag over state", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.dragEnter(dropZone);
|
||||
expect(screen.getByText(/Drop your image here/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets drag over state on drag leave", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.dragEnter(dropZone);
|
||||
fireEvent.dragLeave(dropZone);
|
||||
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles file selection and shows uploading state", async () => {
|
||||
const onUpload = vi.fn();
|
||||
render(<ImageUpload onUpload={onUpload} />);
|
||||
|
||||
// Simulate file selection via the file input
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Uploading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onUpload callback on success", async () => {
|
||||
const onUpload = vi.fn();
|
||||
render(<ImageUpload onUpload={onUpload} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpload).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success state with image details", async () => {
|
||||
render(<ImageUpload />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Upload Successful/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Upload Another/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onError callback when upload fails", async () => {
|
||||
const onError = vi.fn();
|
||||
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Network error")
|
||||
);
|
||||
|
||||
render(<ImageUpload onError={onError} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state with retry and clear buttons", async () => {
|
||||
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Upload failed")
|
||||
);
|
||||
|
||||
render(<ImageUpload />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Upload Failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Retry/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Clear/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onError with validation error", async () => {
|
||||
const onError = vi.fn();
|
||||
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
ok: false,
|
||||
error: "File too large",
|
||||
});
|
||||
|
||||
render(<ImageUpload onError={onError} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith("File too large");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears state when Clear/Upload Another button is clicked", async () => {
|
||||
render(<ImageUpload />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Upload Successful/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(/Upload Another/i));
|
||||
|
||||
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not handle file when disabled", async () => {
|
||||
render(<ImageUpload disabled />);
|
||||
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.click(dropZone);
|
||||
|
||||
expect(uploadApi.uploadImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
113
apps/web/src/components/LoadingSkeleton.test.tsx
Normal file
113
apps/web/src/components/LoadingSkeleton.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import LoadingSkeleton, {
|
||||
ResultsSkeleton,
|
||||
PlantCardSkeleton,
|
||||
UploadSkeleton,
|
||||
} from "@/components/LoadingSkeleton";
|
||||
|
||||
describe("LoadingSkeleton", () => {
|
||||
it("renders default text variant skeleton", () => {
|
||||
render(<LoadingSkeleton />);
|
||||
const container = screen.container;
|
||||
// Default text variant renders 3 lines with animate-pulse
|
||||
const pulseElements = container.querySelectorAll(".animate-pulse");
|
||||
expect(pulseElements.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders skeleton with custom className", () => {
|
||||
render(<LoadingSkeleton className="custom-class" />);
|
||||
const container = screen.container;
|
||||
expect(container.querySelector(".custom-class")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple skeletons when count > 1", () => {
|
||||
render(<LoadingSkeleton count={3} />);
|
||||
// Each text variant has 3 div lines, 3 groups = 9 divs
|
||||
const container = screen.container;
|
||||
const pulseElements = container.querySelectorAll(".animate-pulse");
|
||||
expect(pulseElements.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingSkeleton variants", () => {
|
||||
it("renders card variant with image and text blocks", () => {
|
||||
render(<LoadingSkeleton variant="card" />);
|
||||
const container = screen.container;
|
||||
expect(container.querySelector(".rounded-xl")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders text variant with staggered widths", () => {
|
||||
render(<LoadingSkeleton variant="text" />);
|
||||
const container = screen.container;
|
||||
const lines = container.querySelectorAll(".animate-pulse");
|
||||
expect(lines.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders image variant", () => {
|
||||
render(<LoadingSkeleton variant="image" />);
|
||||
const container = screen.container;
|
||||
const image = container.querySelector(".animate-pulse");
|
||||
expect(image).toBeInTheDocument();
|
||||
expect(image).toHaveClass("h-48");
|
||||
});
|
||||
|
||||
it("renders circle variant", () => {
|
||||
render(<LoadingSkeleton variant="circle" />);
|
||||
const container = screen.container;
|
||||
expect(container.querySelector(".rounded-full")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders row variant with icon and text", () => {
|
||||
render(<LoadingSkeleton variant="row" />);
|
||||
const container = screen.container;
|
||||
const row = container.querySelector(".flex.items-center.gap-4");
|
||||
expect(row).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResultsSkeleton", () => {
|
||||
it("renders a full-page results skeleton with status role", () => {
|
||||
render(<ResultsSkeleton />);
|
||||
const status = screen.getByRole("status", { name: /Loading results/i });
|
||||
expect(status).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image, text, and card sections", () => {
|
||||
render(<ResultsSkeleton />);
|
||||
const container = screen.container;
|
||||
const pulseElements = container.querySelectorAll(".animate-pulse");
|
||||
expect(pulseElements.length).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlantCardSkeleton", () => {
|
||||
it("renders default 6 card skeletons", () => {
|
||||
render(<PlantCardSkeleton />);
|
||||
const container = screen.container;
|
||||
const cards = container.querySelectorAll(".rounded-xl");
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it("renders custom count of card skeletons", () => {
|
||||
render(<PlantCardSkeleton count={3} />);
|
||||
const container = screen.container;
|
||||
const cards = container.querySelectorAll(".rounded-xl");
|
||||
expect(cards.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UploadSkeleton", () => {
|
||||
it("renders upload area skeleton with status role", () => {
|
||||
render(<UploadSkeleton />);
|
||||
const status = screen.getByRole("status", { name: /Loading upload area/i });
|
||||
expect(status).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders circle and text skeletons inside dashed border", () => {
|
||||
render(<UploadSkeleton />);
|
||||
const container = screen.container;
|
||||
expect(container.querySelector(".border-dashed")).toBeInTheDocument();
|
||||
expect(container.querySelector(".rounded-full")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,163 +6,153 @@ import type { Disease } from "@/lib/types";
|
||||
describe("LookalikeWarning", () => {
|
||||
const mockDisease: Disease = {
|
||||
id: "early-blight",
|
||||
plantId: "tomato",
|
||||
name: "Early Blight",
|
||||
scientificName: "Alternaria solani",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
description: "Early blight is a common fungal disease.",
|
||||
symptoms: [
|
||||
"Dark brown spots with concentric rings on lower leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Premature defoliation starting from bottom of plant",
|
||||
],
|
||||
causes: ["Warm temperatures with high humidity"],
|
||||
treatment: ["Remove infected leaves", "Apply fungicide"],
|
||||
prevention: ["Practice crop rotation"],
|
||||
lookalikeDiseaseIds: ["late-blight"],
|
||||
severity: "moderate",
|
||||
symptoms: [
|
||||
"Dark brown spots on lower leaves",
|
||||
"Concentric rings in lesions",
|
||||
"Yellowing of older leaves",
|
||||
],
|
||||
treatment: [
|
||||
"Remove affected leaves",
|
||||
"Apply copper fungicide",
|
||||
"Improve air circulation",
|
||||
],
|
||||
lookalikeDiseaseIds: ["late-blight"],
|
||||
};
|
||||
|
||||
const mockLookalike: Disease = {
|
||||
id: "late-blight",
|
||||
plantId: "tomato",
|
||||
name: "Late Blight",
|
||||
scientificName: "Phytophthora infestans",
|
||||
causalAgent: "Phytophthora infestans",
|
||||
causalAgentType: "fungal",
|
||||
description: "Late blight is a devastating oomycete disease.",
|
||||
severity: "high",
|
||||
symptoms: [
|
||||
"Large irregular dark green to black water-soaked lesions on leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Rapid browning and death of entire leaves and stems",
|
||||
"Dark brown spots on lower leaves",
|
||||
"Water-soaked lesions",
|
||||
"White fungal growth on undersides",
|
||||
],
|
||||
treatment: [
|
||||
"Remove and destroy infected plants",
|
||||
"Apply systemic fungicide",
|
||||
"Crop rotation",
|
||||
],
|
||||
causes: ["Cool temperatures with prolonged leaf wetness"],
|
||||
treatment: ["Remove and destroy infected material", "Apply mancozeb fungicide"],
|
||||
prevention: ["Plant resistant varieties"],
|
||||
lookalikeDiseaseIds: ["early-blight"],
|
||||
severity: "critical",
|
||||
};
|
||||
|
||||
function renderWarning(disease: Disease, lookalikes: Disease[]) {
|
||||
return render(<LookalikeWarning disease={disease} lookalikes={lookalikes} />);
|
||||
}
|
||||
|
||||
describe("renders nothing when no lookalikes", () => {
|
||||
it("returns null for empty lookalikes array", () => {
|
||||
const { container } = renderWarning(mockDisease, []);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
it("renders nothing when lookalikes array is empty", () => {
|
||||
const { container } = render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[]} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
describe("banner header", () => {
|
||||
it("shows warning message with lookalike name", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
expect(screen.getByText(/easily confused with/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Late Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning icon", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
// Warning icon is an SVG with specific path
|
||||
const svg = document.querySelector('svg[aria-hidden="true"]');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
it("renders warning banner with lookalike name", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
expect(screen.getByText(/easily confused with/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Late Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("expand/collapse", () => {
|
||||
it("shows collapsed state by default", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
// Comparison table should not be visible
|
||||
expect(screen.queryByText("Early Blight vs. Late Blight")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands comparison on click", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button", { expanded: false });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("Early Blight vs. Late Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses comparison on second click", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button); // expand
|
||||
fireEvent.click(button); // collapse
|
||||
|
||||
expect(screen.queryByText("Early Blight vs. Late Blight")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles chevron direction", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button", { expanded: false });
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
it("renders plural 's' when multiple lookalikes", () => {
|
||||
render(
|
||||
<LookalikeWarning
|
||||
disease={mockDisease}
|
||||
lookalikes={[mockLookalike, mockDisease]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/easily confused with/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("comparison table", () => {
|
||||
it("shows comparison table header with disease names", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("Symptom")).toBeInTheDocument();
|
||||
// Disease names appear in table headers (th elements)
|
||||
const headers = document.querySelectorAll("th");
|
||||
const headerText = Array.from(headers).map((h) => h.textContent);
|
||||
expect(headerText).toContain("Early Blight");
|
||||
expect(headerText).toContain("Late Blight");
|
||||
it("expands comparison table on click", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
const toggleButton = screen.getByRole("button", {
|
||||
name: /easily confused with/i,
|
||||
});
|
||||
|
||||
it("shows 'Present' for symptoms shared by both diseases", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
// Table should not be visible initially
|
||||
expect(screen.queryByText("Symptom")).not.toBeInTheDocument();
|
||||
|
||||
// "Yellowing of leaves surrounding infected spots" is in both
|
||||
const presentSpans = screen.getAllByText("Present");
|
||||
expect(presentSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
it("shows legend for present/similar indicators", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("Present in both")).toBeInTheDocument();
|
||||
expect(screen.getByText("Similar symptom")).toBeInTheDocument();
|
||||
});
|
||||
// Comparison table should now be visible
|
||||
expect(screen.getByText("Symptom")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Early Blight vs\. Late Blight/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("multiple lookalikes", () => {
|
||||
it("shows all lookalike names in banner", () => {
|
||||
const lookalike2: Disease = {
|
||||
...mockLookalike,
|
||||
id: "septoria-leaf-spot",
|
||||
name: "Septoria Leaf Spot",
|
||||
symptoms: ["Small circular spots with dark borders"],
|
||||
};
|
||||
|
||||
renderWarning(mockDisease, [mockLookalike, lookalike2]);
|
||||
expect(screen.getByText(/easily confused with/)).toBeInTheDocument();
|
||||
it("collapses comparison table on second click", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
const toggleButton = screen.getByRole("button", {
|
||||
name: /easily confused with/i,
|
||||
});
|
||||
|
||||
it("shows comparison for each lookalike when expanded", () => {
|
||||
const lookalike2: Disease = {
|
||||
...mockLookalike,
|
||||
id: "septoria-leaf-spot",
|
||||
name: "Septoria Leaf Spot",
|
||||
symptoms: ["Small circular spots with dark borders"],
|
||||
};
|
||||
// Expand
|
||||
fireEvent.click(toggleButton);
|
||||
expect(screen.getByText("Symptom")).toBeInTheDocument();
|
||||
|
||||
renderWarning(mockDisease, [mockLookalike, lookalike2]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
// Collapse
|
||||
fireEvent.click(toggleButton);
|
||||
expect(screen.queryByText("Symptom")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Early Blight vs. Late Blight")).toBeInTheDocument();
|
||||
expect(screen.getByText("Early Blight vs. Septoria Leaf Spot")).toBeInTheDocument();
|
||||
});
|
||||
it("shows symptom comparison columns", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getAllByText("Early Blight").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Late Blight").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("marks shared symptoms as Present", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// "Dark brown spots on lower leaves" is in both
|
||||
const presentCount = screen.getAllByText("Present");
|
||||
expect(presentCount.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("marks unique symptoms with dash", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// Symptoms unique to one disease should show "—"
|
||||
const dashes = screen.getAllByText("—");
|
||||
expect(dashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders legend for Present and Similar indicators", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByText(/Present in both/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Similar symptom/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has aria-expanded attribute on toggle button", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
const toggleButton = screen.getByRole("button");
|
||||
expect(toggleButton).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
|
||||
106
apps/web/src/components/Navbar.test.tsx
Normal file
106
apps/web/src/components/Navbar.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(() => "/"),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, className, ...props }: any) => (
|
||||
<a href={href} className={className} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Navbar", () => {
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const { useRouter } = require("next/navigation");
|
||||
useRouter.mockReturnValue(mockRouter);
|
||||
});
|
||||
|
||||
it("renders header with app name", () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plant Health ID")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders navigation links", () => {
|
||||
render(<Navbar />);
|
||||
const nav = screen.getByRole("navigation", { name: /Global/i });
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders desktop search form", () => {
|
||||
render(<Navbar />);
|
||||
const searchForm = screen.getByRole("search");
|
||||
expect(searchForm).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to browse page on search submit", () => {
|
||||
render(<Navbar />);
|
||||
const searchForm = screen.getByRole("search");
|
||||
const searchInput = searchForm.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "tomato" } });
|
||||
fireEvent.submit(searchForm);
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith("/browse?search=tomato");
|
||||
});
|
||||
|
||||
it("navigates to browse on empty search", () => {
|
||||
render(<Navbar />);
|
||||
const searchForm = screen.getByRole("search");
|
||||
fireEvent.submit(searchForm);
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith("/browse");
|
||||
});
|
||||
|
||||
it("renders mobile menu toggle button", () => {
|
||||
render(<Navbar />);
|
||||
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles mobile menu on button click", () => {
|
||||
render(<Navbar />);
|
||||
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
|
||||
|
||||
// Open menu
|
||||
fireEvent.click(menuButton);
|
||||
const mobileDialog = screen.getByRole("dialog", { name: /Mobile navigation/i });
|
||||
expect(mobileDialog).toBeInTheDocument();
|
||||
|
||||
// Close menu
|
||||
const closeButton = screen.getByRole("button", { name: /Close navigation menu/i });
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders mobile search form when menu is open", () => {
|
||||
render(<Navbar />);
|
||||
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Mobile search should be in the drawer
|
||||
const mobileSearch = screen.getByRole("search");
|
||||
expect(mobileSearch).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant emoji logo", () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.getByText("🌱")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
72
apps/web/src/components/PlantCard.test.tsx
Normal file
72
apps/web/src/components/PlantCard.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import PlantCard from "@/components/PlantCard";
|
||||
import type { Plant } from "@/data/plants";
|
||||
|
||||
describe("PlantCard", () => {
|
||||
const mockPlant: Plant = {
|
||||
id: "tomato",
|
||||
commonName: "Tomato",
|
||||
scientificName: "Solanum lycopersicum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetables",
|
||||
description: "A popular garden vegetable.",
|
||||
careSummary: "Full sun, well-drained soil.",
|
||||
imageEmoji: "🍅",
|
||||
diseases: [
|
||||
{
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
type: "fungal",
|
||||
description: "A fungal disease.",
|
||||
symptoms: ["Dark spots"],
|
||||
causes: ["Fungus"],
|
||||
treatmentSteps: ["Remove leaves"],
|
||||
preventionTips: ["Rotate crops"],
|
||||
severity: "moderate",
|
||||
},
|
||||
{
|
||||
id: "late-blight",
|
||||
name: "Late Blight",
|
||||
type: "fungal",
|
||||
description: "A devastating disease.",
|
||||
symptoms: ["Water-soaked lesions"],
|
||||
causes: ["Water mold"],
|
||||
treatmentSteps: ["Remove plants"],
|
||||
preventionTips: ["Use resistant varieties"],
|
||||
severity: "high",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("renders plant name", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant emoji", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
expect(screen.getByText("🍅")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant family", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
expect(screen.getByText("Solanaceae")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disease count", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
expect(screen.getByText(/2 diseases tracked/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders link to plant detail page", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/browse/tomato");
|
||||
});
|
||||
|
||||
it("hides disease count when showDiseaseCount is false", () => {
|
||||
render(<PlantCard plant={mockPlant} showDiseaseCount={false} />);
|
||||
expect(screen.queryByText(/diseases tracked/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
350
apps/web/src/components/ResultsDashboard.test.tsx
Normal file
350
apps/web/src/components/ResultsDashboard.test.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ResultsDashboard from "@/components/ResultsDashboard";
|
||||
import type { IdentifyResponse, PredictionResult } from "@/lib/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/components/DiseaseCard", () => ({
|
||||
default: ({ rank, isPrimary, onDismiss, prediction }: any) => (
|
||||
<div data-testid={`disease-card-${rank}`} data-primary={isPrimary}>
|
||||
<span>{prediction.disease.name}</span>
|
||||
<span data-testid={`confidence-${rank}`}>{prediction.confidence.adjusted.toFixed(2)}</span>
|
||||
<button onClick={onDismiss}>Dismiss</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/LoadingSkeleton", () => ({
|
||||
default: () => <div data-testid="loading-skeleton">Loading...</div>,
|
||||
ResultsSkeleton: () => <div data-testid="results-skeleton">Results Loading...</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/EmptyState", () => ({
|
||||
default: ({ title, description, actionLabel, actionHref }: any) => (
|
||||
<div data-testid="empty-state">
|
||||
<span data-testid="empty-title">{title}</span>
|
||||
<span data-testid="empty-desc">{description}</span>
|
||||
{actionLabel && <a href={actionHref}>{actionLabel}</a>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/diseases", () => ({
|
||||
getPlantById: vi.fn(() => ({ id: "tomato", commonName: "Tomato" })),
|
||||
getDiseaseById: vi.fn(),
|
||||
getLookalikeDiseases: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe("ResultsDashboard", () => {
|
||||
const mockPrediction: PredictionResult = {
|
||||
diseaseId: "early-blight",
|
||||
disease: {
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
severity: "moderate",
|
||||
symptoms: ["Dark spots"],
|
||||
treatment: ["Remove leaves"],
|
||||
lookalikeDiseaseIds: [],
|
||||
plantId: "tomato",
|
||||
},
|
||||
confidence: { raw: 0.85, adjusted: 0.82 },
|
||||
lookalikes: [],
|
||||
};
|
||||
|
||||
const mockResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: {
|
||||
model: "mock-model",
|
||||
inferenceTimeMs: 150,
|
||||
imageId: "test-image-123",
|
||||
},
|
||||
};
|
||||
|
||||
it("renders loading skeleton when loading is true", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={null}
|
||||
loading={true}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("results-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state with error when error is provided", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={null}
|
||||
loading={false}
|
||||
error="Something went wrong"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("empty-title")).toHaveTextContent("Identification Failed");
|
||||
expect(screen.getByTestId("empty-desc")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByText("Try again")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when no response and no predictions", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={null}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("empty-title")).toHaveTextContent("No Results Found");
|
||||
});
|
||||
|
||||
it("renders results header with inference time and model", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Identification Results")).toBeInTheDocument();
|
||||
expect(screen.getByText(/150ms/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/mock-model/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders demo mode badge when demo_mode is true", () => {
|
||||
const demoResponse: IdentifyResponse = {
|
||||
...mockResponse,
|
||||
demo_mode: true,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={demoResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Demo mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image preview", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
const img = screen.getByAltText("Uploaded plant image");
|
||||
expect(img).toHaveAttribute("src", "/test.jpg");
|
||||
});
|
||||
|
||||
it("renders image metadata section", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-image-123"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Image ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Predictions")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 shown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disease prediction cards", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("disease-card-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Early Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts predictions by confidence by default", () => {
|
||||
const multiResponse: IdentifyResponse = {
|
||||
predictions: [
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-a",
|
||||
disease: { ...mockPrediction.disease, name: "Disease A", plantId: "tomato" },
|
||||
confidence: { raw: 0.6, adjusted: 0.58 },
|
||||
},
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-b",
|
||||
disease: { ...mockPrediction.disease, name: "Disease B", plantId: "tomato" },
|
||||
confidence: { raw: 0.9, adjusted: 0.88 },
|
||||
},
|
||||
],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={multiResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
// Disease B (higher confidence) should appear first
|
||||
const cards = screen.getAllByRole("button", { name: /Dismiss/i });
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it("sorts predictions by name when sort changed", () => {
|
||||
const multiResponse: IdentifyResponse = {
|
||||
predictions: [
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-b",
|
||||
disease: { ...mockPrediction.disease, name: "Disease B", plantId: "tomato" },
|
||||
confidence: { raw: 0.9, adjusted: 0.88 },
|
||||
},
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-a",
|
||||
disease: { ...mockPrediction.disease, name: "Disease A", plantId: "tomato" },
|
||||
confidence: { raw: 0.6, adjusted: 0.58 },
|
||||
},
|
||||
],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={multiResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Change sort to name
|
||||
const select = screen.getByLabelText(/Sort by/i);
|
||||
fireEvent.change(select, { target: { value: "name" } });
|
||||
|
||||
// Both cards should still be present
|
||||
expect(screen.getByText("Disease A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disease B")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dismisses a prediction when dismiss button is clicked", () => {
|
||||
const multiResponse: IdentifyResponse = {
|
||||
predictions: [
|
||||
mockPrediction,
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "late-blight",
|
||||
disease: { ...mockPrediction.disease, name: "Late Blight", plantId: "tomato" },
|
||||
confidence: { raw: 0.7, adjusted: 0.68 },
|
||||
},
|
||||
],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={multiResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 2 predictions shown
|
||||
expect(screen.getByText("2 shown")).toBeInTheDocument();
|
||||
|
||||
// Dismiss first prediction
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /Dismiss/i })[0]);
|
||||
|
||||
// Should show 1 prediction
|
||||
expect(screen.getByText("1 shown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows restore results link after dismissing all predictions", () => {
|
||||
const singleResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={singleResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
|
||||
|
||||
expect(screen.getByText(/Restore results/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'All results dismissed' when all predictions dismissed", () => {
|
||||
const singleResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={singleResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
|
||||
|
||||
expect(screen.getByText("All results dismissed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores dismissed predictions when clicking restore link", () => {
|
||||
const singleResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={singleResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Dismiss
|
||||
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
|
||||
|
||||
// Verify dismissed state
|
||||
expect(screen.getByText("All results dismissed")).toBeInTheDocument();
|
||||
|
||||
// Restore via the link
|
||||
fireEvent.click(screen.getByText(/Restore results/i));
|
||||
|
||||
// Should be back to showing predictions
|
||||
expect(screen.getByText("1 shown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import TreatmentTimeline, { treatmentStepsWithUrgency, type TreatmentStep } from "@/components/TreatmentTimeline";
|
||||
import TreatmentTimeline, {
|
||||
treatmentStepsWithUrgency,
|
||||
type TreatmentStep,
|
||||
type UrgencyLevel,
|
||||
} from "@/components/TreatmentTimeline";
|
||||
|
||||
describe("TreatmentTimeline", () => {
|
||||
const mockSteps: TreatmentStep[] = [
|
||||
{ action: "Remove and destroy all severely infected leaves immediately", urgency: "immediate" },
|
||||
{ action: "Apply copper-based fungicide spray every 7-10 days", urgency: "within-week" },
|
||||
{ action: "Improve air circulation by pruning lower leaves", urgency: "ongoing" },
|
||||
{ action: "Mulch around base with 2-3 inches of straw", urgency: "ongoing" },
|
||||
{ action: "Switch to drip irrigation to keep foliage dry", urgency: "ongoing" },
|
||||
{ action: "Remove affected leaves immediately", urgency: "immediate" },
|
||||
{ action: "Apply copper fungicide within a week", urgency: "within-week" },
|
||||
{ action: "Monitor plant health regularly", urgency: "ongoing" },
|
||||
];
|
||||
|
||||
function renderTimeline(steps: TreatmentStep[]) {
|
||||
return render(<TreatmentTimeline steps={steps} />);
|
||||
}
|
||||
|
||||
describe("renders all treatment steps", () => {
|
||||
it("shows all step actions", () => {
|
||||
renderTimeline(mockSteps);
|
||||
mockSteps.forEach((step) => {
|
||||
expect(screen.getByText(step.action)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows numbered step indicators", () => {
|
||||
renderTimeline(mockSteps);
|
||||
for (let i = 1; i <= mockSteps.length; i++) {
|
||||
expect(screen.getByText(i.toString())).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows urgency badges for each step", () => {
|
||||
renderTimeline(mockSteps);
|
||||
expect(screen.getByText("Immediate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Within a week")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Ongoing").length).toBeGreaterThan(0);
|
||||
});
|
||||
it("renders all treatment steps", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText("Remove affected leaves immediately")).toBeInTheDocument();
|
||||
expect(screen.getByText("Apply copper fungicide within a week")).toBeInTheDocument();
|
||||
expect(screen.getByText("Monitor plant health regularly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("urgency levels", () => {
|
||||
it("renders immediate urgency with red styling", () => {
|
||||
renderTimeline(mockSteps);
|
||||
const immediateBadge = screen.getByText("Immediate");
|
||||
expect(immediateBadge.closest("span")).toHaveClass("bg-red-100");
|
||||
});
|
||||
|
||||
it("renders within-week urgency with amber styling", () => {
|
||||
renderTimeline(mockSteps);
|
||||
const weekBadge = screen.getByText("Within a week");
|
||||
expect(weekBadge.closest("span")).toHaveClass("bg-warning-amber-100");
|
||||
});
|
||||
|
||||
it("renders ongoing urgency with green styling", () => {
|
||||
renderTimeline(mockSteps);
|
||||
const ongoingBadges = screen.getAllByText("Ongoing");
|
||||
expect(ongoingBadges.length).toBeGreaterThan(0);
|
||||
expect(ongoingBadges[0].closest("span")).toHaveClass("bg-leaf-green-100");
|
||||
});
|
||||
it("renders numbered step indicators", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("disclaimer", () => {
|
||||
it("shows treatment disclaimer at bottom", () => {
|
||||
renderTimeline(mockSteps);
|
||||
expect(screen.getByText(/Treatments may vary depending on plant species/)).toBeInTheDocument();
|
||||
});
|
||||
it("renders urgency badges", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText("Immediate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Within a week")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ongoing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows message when no steps provided", () => {
|
||||
renderTimeline([]);
|
||||
expect(screen.getByText("No treatment steps available.")).toBeInTheDocument();
|
||||
});
|
||||
it("renders disclaimer at bottom", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText(/Treatments may vary/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/consult a certified plant pathologist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("timeline connectors", () => {
|
||||
it("shows connector lines between steps", () => {
|
||||
renderTimeline(mockSteps);
|
||||
// There should be N-1 connector lines
|
||||
const connectors = document.querySelectorAll('.bg-zinc-200.dark\\:bg-zinc-700');
|
||||
// At least some connectors should exist for multi-step timelines
|
||||
expect(connectors.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("renders empty state when no steps provided", () => {
|
||||
render(<TreatmentTimeline steps={[]} />);
|
||||
expect(screen.getByText(/No treatment steps available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("treatmentStepsWithUrgency helper", () => {
|
||||
it("maps first step to immediate", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3"]);
|
||||
expect(steps[0].urgency).toBe("immediate");
|
||||
});
|
||||
it("renders single step without connector line", () => {
|
||||
render(<TreatmentTimeline steps={[mockSteps[0]]} />);
|
||||
expect(screen.getByText("Remove affected leaves immediately")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps second step to within-week", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3"]);
|
||||
expect(steps[1].urgency).toBe("within-week");
|
||||
});
|
||||
|
||||
it("maps remaining steps to ongoing", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3", "Step 4"]);
|
||||
expect(steps[2].urgency).toBe("ongoing");
|
||||
expect(steps[3].urgency).toBe("ongoing");
|
||||
});
|
||||
|
||||
it("preserves action text", () => {
|
||||
const actions = ["Remove leaves", "Apply fungicide", "Improve circulation"];
|
||||
const steps = treatmentStepsWithUrgency(actions);
|
||||
expect(steps.map((s) => s.action)).toEqual(actions);
|
||||
});
|
||||
|
||||
it("handles single step", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Only step"]);
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0].urgency).toBe("immediate");
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
const steps = treatmentStepsWithUrgency([]);
|
||||
expect(steps).toHaveLength(0);
|
||||
});
|
||||
it("uses list role for timeline", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
const list = screen.getByRole("list");
|
||||
expect(list).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("treatmentStepsWithUrgency", () => {
|
||||
it("maps first step to immediate urgency", () => {
|
||||
const steps = treatmentStepsWithUrgency(["First action"]);
|
||||
expect(steps[0].urgency).toBe("immediate");
|
||||
expect(steps[0].action).toBe("First action");
|
||||
});
|
||||
|
||||
it("maps second step to within-week urgency", () => {
|
||||
const steps = treatmentStepsWithUrgency(["First", "Second"]);
|
||||
expect(steps[1].urgency).toBe("within-week");
|
||||
});
|
||||
|
||||
it("maps remaining steps to ongoing urgency", () => {
|
||||
const steps = treatmentStepsWithUrgency(["First", "Second", "Third", "Fourth"]);
|
||||
expect(steps[2].urgency).toBe("ongoing");
|
||||
expect(steps[3].urgency).toBe("ongoing");
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
const steps = treatmentStepsWithUrgency([]);
|
||||
expect(steps).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves action text", () => {
|
||||
const actions = ["Action A", "Action B", "Action C"];
|
||||
const steps = treatmentStepsWithUrgency(actions);
|
||||
expect(steps.map((s) => s.action)).toEqual(actions);
|
||||
});
|
||||
});
|
||||
|
||||
169
apps/web/src/data/plants.test.ts
Normal file
169
apps/web/src/data/plants.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
plants,
|
||||
getPlantById,
|
||||
getPlantsByCategory,
|
||||
getFeaturedPlants,
|
||||
getAllDiseaseTypes,
|
||||
searchPlants,
|
||||
} from "./plants";
|
||||
|
||||
describe("plants data", () => {
|
||||
it("exports a non-empty array of plants", () => {
|
||||
expect(Array.isArray(plants)).toBe(true);
|
||||
expect(plants.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each plant has required fields", () => {
|
||||
for (const plant of plants) {
|
||||
expect(plant).toHaveProperty("id");
|
||||
expect(plant).toHaveProperty("commonName");
|
||||
expect(plant).toHaveProperty("scientificName");
|
||||
expect(plant).toHaveProperty("family");
|
||||
expect(plant).toHaveProperty("category");
|
||||
expect(plant).toHaveProperty("description");
|
||||
expect(plant).toHaveProperty("careSummary");
|
||||
expect(plant).toHaveProperty("imageEmoji");
|
||||
expect(plant).toHaveProperty("diseases");
|
||||
expect(Array.isArray(plant.diseases)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("each plant has at least one disease", () => {
|
||||
for (const plant of plants) {
|
||||
expect(plant.diseases.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("each disease has required fields", () => {
|
||||
for (const plant of plants) {
|
||||
for (const disease of plant.diseases) {
|
||||
expect(disease).toHaveProperty("id");
|
||||
expect(disease).toHaveProperty("name");
|
||||
expect(disease).toHaveProperty("type");
|
||||
expect(disease).toHaveProperty("description");
|
||||
expect(disease).toHaveProperty("symptoms");
|
||||
expect(disease).toHaveProperty("causes");
|
||||
expect(disease).toHaveProperty("treatmentSteps");
|
||||
expect(disease).toHaveProperty("preventionTips");
|
||||
expect(disease).toHaveProperty("severity");
|
||||
expect(Array.isArray(disease.symptoms)).toBe(true);
|
||||
expect(Array.isArray(disease.treatmentSteps)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlantById", () => {
|
||||
it("returns plant when id exists", () => {
|
||||
const plant = getPlantById("tomato");
|
||||
expect(plant).toBeDefined();
|
||||
expect(plant!.commonName).toBe("Tomato");
|
||||
});
|
||||
|
||||
it("returns undefined when id does not exist", () => {
|
||||
const plant = getPlantById("nonexistent");
|
||||
expect(plant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is case sensitive", () => {
|
||||
const plant = getPlantById("Tomato");
|
||||
expect(plant).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlantsByCategory", () => {
|
||||
it("returns plants in the vegetables category", () => {
|
||||
const veggies = getPlantsByCategory("vegetables");
|
||||
expect(veggies.length).toBeGreaterThan(0);
|
||||
expect(veggies.every((p) => p.category === "vegetables")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plants in the herbs category", () => {
|
||||
const herbs = getPlantsByCategory("herbs");
|
||||
expect(herbs.length).toBeGreaterThan(0);
|
||||
expect(herbs.every((p) => p.category === "herbs")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plants in the flowers category", () => {
|
||||
const flowers = getPlantsByCategory("flowers");
|
||||
expect(flowers.length).toBeGreaterThan(0);
|
||||
expect(flowers.every((p) => p.category === "flowers")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plants in the houseplants category", () => {
|
||||
const houseplants = getPlantsByCategory("houseplants");
|
||||
expect(houseplants.length).toBeGreaterThan(0);
|
||||
expect(houseplants.every((p) => p.category === "houseplants")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeaturedPlants", () => {
|
||||
it("returns a subset of plants", () => {
|
||||
const featured = getFeaturedPlants();
|
||||
expect(featured.length).toBeGreaterThan(0);
|
||||
expect(featured.length).toBeLessThanOrEqual(plants.length);
|
||||
});
|
||||
|
||||
it("returns expected featured plants", () => {
|
||||
const featured = getFeaturedPlants();
|
||||
const ids = featured.map((p) => p.id);
|
||||
expect(ids).toContain("tomato");
|
||||
expect(ids).toContain("basil");
|
||||
expect(ids).toContain("rose");
|
||||
expect(ids).toContain("monstera");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllDiseaseTypes", () => {
|
||||
it("returns unique disease types", () => {
|
||||
const types = getAllDiseaseTypes();
|
||||
expect(types.length).toBe(new Set(types).size);
|
||||
});
|
||||
|
||||
it("includes expected disease types", () => {
|
||||
const types = getAllDiseaseTypes();
|
||||
expect(types).toContain("fungal");
|
||||
expect(types).toContain("bacterial");
|
||||
expect(types).toContain("physiological");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchPlants", () => {
|
||||
it("returns all plants for empty query", () => {
|
||||
const results = searchPlants("");
|
||||
expect(results).toEqual(plants);
|
||||
});
|
||||
|
||||
it("returns all plants for whitespace query", () => {
|
||||
const results = searchPlants(" ");
|
||||
expect(results).toEqual(plants);
|
||||
});
|
||||
|
||||
it("finds plants by common name", () => {
|
||||
const results = searchPlants("tomato");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].commonName).toBe("Tomato");
|
||||
});
|
||||
|
||||
it("finds plants by scientific name", () => {
|
||||
const results = searchPlants("solanum");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("finds plants by disease name", () => {
|
||||
const results = searchPlants("root rot");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
const lower = searchPlants("tomato");
|
||||
const upper = searchPlants("TOMATO");
|
||||
expect(lower.length).toBe(upper.length);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const results = searchPlants("xyznonexistent123");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
107
apps/web/src/lib/api/identify-client.test.ts
Normal file
107
apps/web/src/lib/api/identify-client.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { identifyPlant } from "./identify";
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("identifyPlant", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("identifies plant and returns predictions", async () => {
|
||||
const mockResponse = {
|
||||
predictions: [
|
||||
{
|
||||
diseaseId: "early-blight",
|
||||
disease: {
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
severity: "moderate",
|
||||
symptoms: ["Dark spots"],
|
||||
treatment: ["Remove leaves"],
|
||||
lookalikeDiseaseIds: [],
|
||||
plantId: "tomato",
|
||||
},
|
||||
confidence: { raw: 0.85, adjusted: 0.82 },
|
||||
lookalikes: [],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
model: "mock-model",
|
||||
inferenceTimeMs: 150,
|
||||
imageId: "test-image-123",
|
||||
},
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await identifyPlant("test-image-123");
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("calls fetch with correct URL and method", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ predictions: [], metadata: {} }),
|
||||
});
|
||||
|
||||
await identifyPlant("test-id");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/identify"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends imageId in request body", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ predictions: [], metadata: {} }),
|
||||
});
|
||||
|
||||
await identifyPlant("test-id");
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0][1];
|
||||
const body = JSON.parse(callArgs.body);
|
||||
expect(body.imageId).toBe("test-id");
|
||||
});
|
||||
|
||||
it("throws error when response is not ok", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
await expect(identifyPlant("test-id")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws error when fetch fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(identifyPlant("test-id")).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("handles demo mode response", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
predictions: [],
|
||||
metadata: {},
|
||||
demo_mode: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await identifyPlant("test-id");
|
||||
expect(result.demo_mode).toBe(true);
|
||||
});
|
||||
});
|
||||
94
apps/web/src/lib/api/upload-client.test.ts
Normal file
94
apps/web/src/lib/api/upload-client.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { uploadImage } from "./upload";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/image-processing", () => ({
|
||||
validateImageFile: vi.fn(() => ({ ok: true })),
|
||||
validateImageDimensions: vi.fn(() => Promise.resolve({ ok: true })),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("uploadImage", () => {
|
||||
const mockFile = new File(["dummy"], "test.png", { type: "image/png" });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uploads image and returns response", async () => {
|
||||
const mockResponse = {
|
||||
imageId: "test-id-123",
|
||||
tensorShape: [3, 224, 224],
|
||||
previewUrl: "/uploads/test-id-123.png",
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await uploadImage(mockFile);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("calls fetch with correct URL", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ imageId: "test", tensorShape: [3, 224, 224], previewUrl: "/test.png" }),
|
||||
});
|
||||
|
||||
await uploadImage(mockFile);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/upload",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends FormData with image field", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ imageId: "test", tensorShape: [3, 224, 224], previewUrl: "/test.png" }),
|
||||
});
|
||||
|
||||
await uploadImage(mockFile);
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0][1];
|
||||
expect(callArgs.body).toBeInstanceOf(FormData);
|
||||
});
|
||||
|
||||
it("throws error when response is not ok", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
json: async () => ({ error: "File too large", message: "File exceeds 10MB limit" }),
|
||||
});
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws error when fetch fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("throws error when file validation fails", async () => {
|
||||
const { validateImageFile } = require("@/lib/image-processing");
|
||||
validateImageFile.mockReturnValue({ ok: false, error: "Invalid file type" });
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow("Validation: Invalid file type");
|
||||
});
|
||||
|
||||
it("throws error when dimension validation fails", async () => {
|
||||
const { validateImageDimensions } = require("@/lib/image-processing");
|
||||
validateImageDimensions.mockResolvedValue({ ok: false, error: "Image too small" });
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow("Validation: Image too small");
|
||||
});
|
||||
});
|
||||
173
apps/web/src/lib/constants.test.ts
Normal file
173
apps/web/src/lib/constants.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
APP_NAME,
|
||||
NAV_LINKS,
|
||||
PLANT_CATEGORIES,
|
||||
TRUST_SIGNALS,
|
||||
HOW_IT_WORKS,
|
||||
BETA_DISCLAIMER,
|
||||
SOCIAL_LINKS,
|
||||
FEATURED_PLANT_IDS,
|
||||
APP_TAGLINE,
|
||||
APP_DESCRIPTION,
|
||||
} from "./constants";
|
||||
|
||||
describe("constants", () => {
|
||||
describe("APP_NAME", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof APP_NAME).toBe("string");
|
||||
expect(APP_NAME.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("equals expected name", () => {
|
||||
expect(APP_NAME).toBe("Plant Health ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("APP_TAGLINE", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof APP_TAGLINE).toBe("string");
|
||||
expect(APP_TAGLINE.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("APP_DESCRIPTION", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof APP_DESCRIPTION).toBe("string");
|
||||
expect(APP_DESCRIPTION.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOCIAL_LINKS", () => {
|
||||
it("has github link", () => {
|
||||
expect(SOCIAL_LINKS.github).toMatch(/github/);
|
||||
});
|
||||
|
||||
it("has twitter link", () => {
|
||||
expect(SOCIAL_LINKS.twitter).toMatch(/twitter/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NAV_LINKS", () => {
|
||||
it("is an array of navigation links", () => {
|
||||
expect(Array.isArray(NAV_LINKS)).toBe(true);
|
||||
expect(NAV_LINKS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each link has label and href", () => {
|
||||
for (const link of NAV_LINKS) {
|
||||
expect(link).toHaveProperty("label");
|
||||
expect(link).toHaveProperty("href");
|
||||
expect(typeof link.label).toBe("string");
|
||||
expect(typeof link.href).toBe("string");
|
||||
expect(link.href.startsWith("/")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes expected routes", () => {
|
||||
const hrefs = NAV_LINKS.map((l) => l.href);
|
||||
expect(hrefs).toContain("/");
|
||||
expect(hrefs).toContain("/browse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLANT_CATEGORIES", () => {
|
||||
it("is an array of categories", () => {
|
||||
expect(Array.isArray(PLANT_CATEGORIES)).toBe(true);
|
||||
expect(PLANT_CATEGORIES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each category has label and value", () => {
|
||||
for (const cat of PLANT_CATEGORIES) {
|
||||
expect(cat).toHaveProperty("label");
|
||||
expect(cat).toHaveProperty("value");
|
||||
expect(typeof cat.label).toBe("string");
|
||||
expect(typeof cat.value).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes all option", () => {
|
||||
const values = PLANT_CATEGORIES.map((c) => c.value);
|
||||
expect(values).toContain("all");
|
||||
});
|
||||
|
||||
it("includes common plant categories", () => {
|
||||
const values = PLANT_CATEGORIES.map((c) => c.value);
|
||||
expect(values).toContain("vegetables");
|
||||
expect(values).toContain("flowers");
|
||||
expect(values).toContain("herbs");
|
||||
expect(values).toContain("houseplants");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FEATURED_PLANT_IDS", () => {
|
||||
it("is an array of plant IDs", () => {
|
||||
expect(Array.isArray(FEATURED_PLANT_IDS)).toBe(true);
|
||||
expect(FEATURED_PLANT_IDS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes expected featured plants", () => {
|
||||
expect(FEATURED_PLANT_IDS).toContain("tomato");
|
||||
expect(FEATURED_PLANT_IDS).toContain("basil");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TRUST_SIGNALS", () => {
|
||||
it("is an array of trust signals", () => {
|
||||
expect(Array.isArray(TRUST_SIGNALS)).toBe(true);
|
||||
expect(TRUST_SIGNALS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each signal has icon and label", () => {
|
||||
for (const signal of TRUST_SIGNALS) {
|
||||
expect(signal).toHaveProperty("icon");
|
||||
expect(signal).toHaveProperty("label");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOW_IT_WORKS", () => {
|
||||
it("is an array of steps", () => {
|
||||
expect(Array.isArray(HOW_IT_WORKS)).toBe(true);
|
||||
expect(HOW_IT_WORKS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each step has step number, emoji, title, and description", () => {
|
||||
for (const step of HOW_IT_WORKS) {
|
||||
expect(step).toHaveProperty("step");
|
||||
expect(step).toHaveProperty("emoji");
|
||||
expect(step).toHaveProperty("title");
|
||||
expect(step).toHaveProperty("description");
|
||||
}
|
||||
});
|
||||
|
||||
it("has exactly 3 steps", () => {
|
||||
expect(HOW_IT_WORKS.length).toBe(3);
|
||||
});
|
||||
|
||||
it("steps are numbered sequentially", () => {
|
||||
expect(HOW_IT_WORKS[0].step).toBe(1);
|
||||
expect(HOW_IT_WORKS[1].step).toBe(2);
|
||||
expect(HOW_IT_WORKS[2].step).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BETA_DISCLAIMER", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof BETA_DISCLAIMER).toBe("string");
|
||||
expect(BETA_DISCLAIMER.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mentions AI-assisted tool", () => {
|
||||
expect(BETA_DISCLAIMER.toLowerCase()).toContain("ai-assisted");
|
||||
});
|
||||
|
||||
it("mentions professional advice disclaimer", () => {
|
||||
expect(BETA_DISCLAIMER.toLowerCase()).toMatch(/not a substitute|professional/i);
|
||||
});
|
||||
|
||||
it("mentions plant pathologist", () => {
|
||||
expect(BETA_DISCLAIMER.toLowerCase()).toContain("plant pathologist");
|
||||
});
|
||||
});
|
||||
});
|
||||
371
apps/web/src/lib/db.ts
Normal file
371
apps/web/src/lib/db.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Turso/libSQL Database Client
|
||||
*
|
||||
* Provides the database client and schema management for the plant disease
|
||||
* knowledge base. Connect to Turso/libSQL using environment variables.
|
||||
*
|
||||
* Required env vars:
|
||||
* DATABASE_URL — Turso database URL (e.g., libsql://my-db.turso.io)
|
||||
* DATABASE_TOKEN — Turso authentication token
|
||||
*/
|
||||
|
||||
import { createClient, type InValue } from "@libsql/client";
|
||||
import type { Plant, Disease, CausalAgentType, Severity } from "./types";
|
||||
|
||||
// ─── Client ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let client: ReturnType<typeof createClient> | null = null;
|
||||
let connected = false;
|
||||
|
||||
/** Get or create a singleton database client */
|
||||
export function getDb() {
|
||||
if (client) return client;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const token = process.env.DATABASE_TOKEN;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"DATABASE_URL is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"DATABASE_TOKEN is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
|
||||
client = createClient({ url, authToken: token });
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Check database connectivity */
|
||||
export async function checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.execute("SELECT 1 AS ok");
|
||||
connected = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
connected = false;
|
||||
console.error("[DB] Connection failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** SQL to create the plants table */
|
||||
const PLANTS_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS plants (
|
||||
id TEXT PRIMARY KEY,
|
||||
common_name TEXT NOT NULL,
|
||||
scientific_name TEXT NOT NULL,
|
||||
family TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
care_summary TEXT NOT NULL DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plants_category ON plants(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_plants_common_name ON plants(common_name);
|
||||
`;
|
||||
|
||||
/** SQL to create the diseases table */
|
||||
const DISEASES_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS diseases (
|
||||
id TEXT PRIMARY KEY,
|
||||
plant_id TEXT NOT NULL REFERENCES plants(id),
|
||||
name TEXT NOT NULL,
|
||||
scientific_name TEXT NOT NULL DEFAULT '',
|
||||
causal_agent_type TEXT NOT NULL CHECK (causal_agent_type IN ('fungal','bacterial','viral','environmental')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
symptoms TEXT NOT NULL DEFAULT '[]',
|
||||
causes TEXT NOT NULL DEFAULT '[]',
|
||||
treatment TEXT NOT NULL DEFAULT '[]',
|
||||
prevention TEXT NOT NULL DEFAULT '[]',
|
||||
lookalike_ids TEXT NOT NULL DEFAULT '[]',
|
||||
severity TEXT NOT NULL CHECK (severity IN ('low','moderate','high','critical')),
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_diseases_plant_id ON diseases(plant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_diseases_causal_agent ON diseases(causal_agent_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_diseases_severity ON diseases(severity);
|
||||
|
||||
-- Full-text search virtual table for diseases
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS diseases_fts USING fts5(
|
||||
name,
|
||||
scientific_name,
|
||||
description,
|
||||
symptoms_text,
|
||||
content='diseases',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
`;
|
||||
|
||||
/** SQL to create the scrape_log table for tracking source freshness */
|
||||
const SCRAPE_LOG_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS scrape_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('wikipedia','university_extension','cabi','other')),
|
||||
source_url TEXT NOT NULL,
|
||||
last_scraped_at TEXT,
|
||||
entries_count INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','success','error')),
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`;
|
||||
|
||||
/** Run all schema migrations */
|
||||
export async function runSchema() {
|
||||
const db = getDb();
|
||||
console.log("[DB] Running schema migrations...");
|
||||
|
||||
await db.execute(PLANTS_TABLE_SQL);
|
||||
console.log("[DB] ✓ plants table");
|
||||
|
||||
await db.execute(DISEASES_TABLE_SQL);
|
||||
console.log("[DB] ✓ diseases table");
|
||||
|
||||
await db.execute(SCRAPE_LOG_SQL);
|
||||
console.log("[DB] ✓ scrape_sources table");
|
||||
|
||||
console.log("[DB] Schema up to date.");
|
||||
}
|
||||
|
||||
// ─── Row ↔ Type mappers ─────────────────────────────────────────────────────
|
||||
|
||||
/** Convert a database row to a Plant object */
|
||||
export function rowToPlant(row: Record<string, unknown>): Plant {
|
||||
return {
|
||||
id: row.id as string,
|
||||
commonName: row.common_name as string,
|
||||
scientificName: row.scientific_name as string,
|
||||
family: row.family as string,
|
||||
category: row.category as Plant["category"],
|
||||
careSummary: row.care_summary as string,
|
||||
imageUrl: row.image_url as string,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a database row to a Disease object */
|
||||
export function rowToDisease(row: Record<string, unknown>): Disease {
|
||||
return {
|
||||
id: row.id as string,
|
||||
plantId: row.plant_id as string,
|
||||
name: row.name as string,
|
||||
scientificName: row.scientific_name as string,
|
||||
causalAgentType: row.causal_agent_type as CausalAgentType,
|
||||
description: row.description as string,
|
||||
symptoms: JSON.parse(row.symptoms as string) as string[],
|
||||
causes: JSON.parse(row.causes as string) as string[],
|
||||
treatment: JSON.parse(row.treatment as string) as string[],
|
||||
prevention: JSON.parse(row.prevention as string) as string[],
|
||||
lookalikeDiseaseIds: JSON.parse(row.lookalike_ids as string) as string[],
|
||||
severity: row.severity as Severity,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a Plant object to database column values */
|
||||
export function plantToRow(plant: Plant): Record<string, InValue> {
|
||||
return {
|
||||
id: plant.id,
|
||||
common_name: plant.commonName,
|
||||
scientific_name: plant.scientificName,
|
||||
family: plant.family,
|
||||
category: plant.category,
|
||||
care_summary: plant.careSummary,
|
||||
image_url: plant.imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a Disease object to database column values */
|
||||
export function diseaseToRow(disease: Disease & { sourceUrl?: string }): Record<string, InValue> {
|
||||
return {
|
||||
id: disease.id,
|
||||
plant_id: disease.plantId,
|
||||
name: disease.name,
|
||||
scientific_name: disease.scientificName,
|
||||
causal_agent_type: disease.causalAgentType,
|
||||
description: disease.description,
|
||||
symptoms: JSON.stringify(disease.symptoms),
|
||||
causes: JSON.stringify(disease.causes),
|
||||
treatment: JSON.stringify(disease.treatment),
|
||||
prevention: JSON.stringify(disease.prevention),
|
||||
lookalike_ids: JSON.stringify(disease.lookalikeDiseaseIds),
|
||||
severity: disease.severity,
|
||||
source_url: disease.sourceUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Query helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Insert or replace a plant */
|
||||
export async function upsertPlant(plant: Plant) {
|
||||
const db = getDb();
|
||||
const row = plantToRow(plant);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
|
||||
await db.execute(`INSERT OR REPLACE INTO plants (${columns}) VALUES (${placeholders})`, values);
|
||||
}
|
||||
|
||||
/** Insert or replace a disease */
|
||||
export async function upsertDisease(disease: Disease & { sourceUrl?: string }) {
|
||||
const db = getDb();
|
||||
const row = diseaseToRow(disease);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
|
||||
await db.execute(`INSERT OR REPLACE INTO diseases (${columns}) VALUES (${placeholders})`, values);
|
||||
}
|
||||
|
||||
/** Bulk insert plants in a transaction */
|
||||
export async function bulkUpsertPlants(plants: Plant[]) {
|
||||
const db = getDb();
|
||||
const tx = await db.transaction("write");
|
||||
try {
|
||||
for (const plant of plants) {
|
||||
const row = plantToRow(plant);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
await tx.execute({
|
||||
sql: `INSERT OR REPLACE INTO plants (${columns}) VALUES (${placeholders})`,
|
||||
args: values,
|
||||
});
|
||||
}
|
||||
await tx.commit();
|
||||
console.log(`[DB] Inserted/replaced ${plants.length} plants`);
|
||||
} catch (err) {
|
||||
await tx.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk insert diseases in a transaction */
|
||||
export async function bulkUpsertDiseases(diseases: Array<Disease & { sourceUrl?: string }>) {
|
||||
const db = getDb();
|
||||
const tx = await db.transaction("write");
|
||||
try {
|
||||
for (const disease of diseases) {
|
||||
const row = diseaseToRow(disease);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
await tx.execute({
|
||||
sql: `INSERT OR REPLACE INTO diseases (${columns}) VALUES (${placeholders})`,
|
||||
args: values,
|
||||
});
|
||||
}
|
||||
await tx.commit();
|
||||
console.log(`[DB] Inserted/replaced ${diseases.length} diseases`);
|
||||
} catch (err) {
|
||||
await tx.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all plants */
|
||||
export async function getAllPlants(): Promise<Plant[]> {
|
||||
const db = getDb();
|
||||
const result = await db.execute("SELECT * FROM plants ORDER BY common_name");
|
||||
return result.rows.map((r) => rowToPlant(r as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** Get all diseases (optionally filtered by plant_id) */
|
||||
export async function getDiseases(plantId?: string): Promise<Disease[]> {
|
||||
const db = getDb();
|
||||
let sql = "SELECT * FROM diseases";
|
||||
const params: InValue[] = [];
|
||||
if (plantId) {
|
||||
sql += " WHERE plant_id = ?";
|
||||
params.push(plantId);
|
||||
}
|
||||
sql += " ORDER BY name";
|
||||
const result = await db.execute(sql, params);
|
||||
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** Get a single plant by ID */
|
||||
export async function getPlantById(plantId: string): Promise<Plant | null> {
|
||||
const db = getDb();
|
||||
const result = await db.execute("SELECT * FROM plants WHERE id = ?", [plantId]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToPlant(result.rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** Get a single disease by ID */
|
||||
export async function getDiseaseById(diseaseId: string): Promise<Disease | null> {
|
||||
const db = getDb();
|
||||
const result = await db.execute("SELECT * FROM diseases WHERE id = ?", [diseaseId]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToDisease(result.rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** Search diseases via FTS */
|
||||
export async function searchDiseasesFts(searchTerm: string): Promise<Disease[]> {
|
||||
const db = getDb();
|
||||
try {
|
||||
const result = await db.execute(
|
||||
`SELECT d.* FROM diseases d
|
||||
JOIN diseases_fts fts ON d.rowid = fts.rowid
|
||||
WHERE diseases_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 50`,
|
||||
[searchTerm],
|
||||
);
|
||||
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
|
||||
} catch {
|
||||
// FTS might not be populated yet; fall back to LIKE search
|
||||
const likeTerm = `%${searchTerm}%`;
|
||||
const result = await db.execute(
|
||||
`SELECT * FROM diseases
|
||||
WHERE name LIKE ? OR description LIKE ? OR scientific_name LIKE ?
|
||||
LIMIT 50`,
|
||||
[likeTerm, likeTerm, likeTerm],
|
||||
);
|
||||
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
/** Get database stats */
|
||||
export async function getDbStats(): Promise<{
|
||||
plants: number;
|
||||
diseases: number;
|
||||
byType: Record<string, number>;
|
||||
bySeverity: Record<string, number>;
|
||||
}> {
|
||||
const db = getDb();
|
||||
const plantCount = await db.execute("SELECT COUNT(*) as cnt FROM plants");
|
||||
const diseaseCount = await db.execute("SELECT COUNT(*) as cnt FROM diseases");
|
||||
const byType = await db.execute(
|
||||
"SELECT causal_agent_type, COUNT(*) as cnt FROM diseases GROUP BY causal_agent_type",
|
||||
);
|
||||
const bySeverity = await db.execute(
|
||||
"SELECT severity, COUNT(*) as cnt FROM diseases GROUP BY severity",
|
||||
);
|
||||
|
||||
return {
|
||||
plants: plantCount.rows[0].cnt as number,
|
||||
diseases: diseaseCount.rows[0].cnt as number,
|
||||
byType: Object.fromEntries(byType.rows.map((r) => [r.causal_agent_type, r.cnt as number])),
|
||||
bySeverity: Object.fromEntries(bySeverity.rows.map((r) => [r.severity, r.cnt as number])),
|
||||
};
|
||||
}
|
||||
62
apps/web/src/lib/db/index.ts
Normal file
62
apps/web/src/lib/db/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Drizzle ORM Database Client for Turso/libSQL.
|
||||
*
|
||||
* Provides the configured drizzle instance and convenience helpers.
|
||||
* Reads DATABASE_URL and DATABASE_TOKEN from environment.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import { createClient } from "@libsql/client";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export type { PlantRow, PlantInsert, DiseaseRow, DiseaseInsert } from "./schema";
|
||||
|
||||
export { schema };
|
||||
|
||||
let _db: LibSQLDatabase<typeof schema> | null = null;
|
||||
let _client: ReturnType<typeof createClient> | null = null;
|
||||
|
||||
/** Get or create the Drizzle database instance (singleton). */
|
||||
export function getDb(): LibSQLDatabase<typeof schema> {
|
||||
if (_db) return _db;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const token = process.env.DATABASE_TOKEN;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"DATABASE_URL is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"DATABASE_TOKEN is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
|
||||
_client = createClient({ url, authToken: token });
|
||||
_db = drizzle(_client, { schema });
|
||||
return _db;
|
||||
}
|
||||
|
||||
/** Check database connectivity. */
|
||||
export async function checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const result = await db.run(sql`SELECT 1 AS ok`);
|
||||
return result.rowsAffected >= 0;
|
||||
} catch (err) {
|
||||
console.error("[DB] Connection failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the client connection. */
|
||||
export function closeDb() {
|
||||
if (_client) {
|
||||
_client.close();
|
||||
_client = null;
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
104
apps/web/src/lib/db/schema.ts
Normal file
104
apps/web/src/lib/db/schema.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Drizzle ORM Schema for the Plant Disease Knowledge Base.
|
||||
*
|
||||
* Uses Turso (libSQL) with SQLite dialect.
|
||||
* Arrays (symptoms, causes, treatment, prevention, lookalike_ids)
|
||||
* are stored as JSON text columns and typed via Drizzle's $type().
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// ─── Plants Table ────────────────────────────────────────────────────────────
|
||||
|
||||
export const plants = sqliteTable(
|
||||
"plants",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
commonName: text("common_name").notNull(),
|
||||
scientificName: text("scientific_name").notNull(),
|
||||
family: text("family").notNull(),
|
||||
category: text("category").notNull(),
|
||||
careSummary: text("care_summary").notNull().default(""),
|
||||
imageUrl: text("image_url").notNull().default(""),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
},
|
||||
(table) => ({
|
||||
categoryIdx: index("idx_plants_category").on(table.category),
|
||||
commonNameIdx: index("idx_plants_common_name").on(table.commonName),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Diseases Table ──────────────────────────────────────────────────────────
|
||||
|
||||
export const diseases = sqliteTable(
|
||||
"diseases",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
plantId: text("plant_id")
|
||||
.notNull()
|
||||
.references(() => plants.id),
|
||||
name: text("name").notNull(),
|
||||
scientificName: text("scientific_name").notNull().default(""),
|
||||
causalAgentType: text("causal_agent_type", {
|
||||
enum: ["fungal", "bacterial", "viral", "environmental"],
|
||||
}).notNull(),
|
||||
description: text("description").notNull().default(""),
|
||||
symptoms: text("symptoms", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
causes: text("causes", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
treatment: text("treatment", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
prevention: text("prevention", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
lookalikeIds: text("lookalike_ids", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
severity: text("severity", {
|
||||
enum: ["low", "moderate", "high", "critical"],
|
||||
}).notNull(),
|
||||
sourceUrl: text("source_url").notNull().default(""),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
},
|
||||
(table) => ({
|
||||
plantIdIdx: index("idx_diseases_plant_id").on(table.plantId),
|
||||
causalAgentIdx: index("idx_diseases_causal_agent").on(table.causalAgentType),
|
||||
severityIdx: index("idx_diseases_severity").on(table.severity),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Scrape Sources Table ────────────────────────────────────────────────────
|
||||
|
||||
export const scrapeSources = sqliteTable("scrape_sources", {
|
||||
id: text("id").primaryKey(),
|
||||
sourceType: text("source_type", {
|
||||
enum: ["wikipedia", "university_extension", "cabi", "other"],
|
||||
}).notNull(),
|
||||
sourceUrl: text("source_url").notNull(),
|
||||
lastScrapedAt: text("last_scraped_at"),
|
||||
entriesCount: integer("entries_count").default(0),
|
||||
status: text("status", { enum: ["pending", "success", "error"] })
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
errorMessage: text("error_message"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
});
|
||||
|
||||
// ─── Relation Inference ──────────────────────────────────────────────────────
|
||||
|
||||
export const plantsRelations = {};
|
||||
export const diseasesRelations = {};
|
||||
|
||||
// ─── Type helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export type PlantRow = typeof plants.$inferSelect;
|
||||
export type PlantInsert = typeof plants.$inferInsert;
|
||||
export type DiseaseRow = typeof diseases.$inferSelect;
|
||||
export type DiseaseInsert = typeof diseases.$inferInsert;
|
||||
42
apps/web/src/lib/server/image-processing-server.test.ts
Normal file
42
apps/web/src/lib/server/image-processing-server.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mimeTypeToExtension } from "./image-processing-server";
|
||||
|
||||
// Mock sharp dynamically
|
||||
const mockSharp = vi.fn(() => ({
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("resized-image-data")),
|
||||
}));
|
||||
|
||||
vi.doMock("sharp", () => ({
|
||||
default: mockSharp,
|
||||
}));
|
||||
|
||||
describe("mimeTypeToExtension", () => {
|
||||
it("maps image/png to png", () => {
|
||||
expect(mimeTypeToExtension("image/png")).toBe("png");
|
||||
});
|
||||
|
||||
it("maps image/jpeg to jpg", () => {
|
||||
expect(mimeTypeToExtension("image/jpeg")).toBe("jpg");
|
||||
});
|
||||
|
||||
it("maps image/jpg to jpg", () => {
|
||||
expect(mimeTypeToExtension("image/jpg")).toBe("jpg");
|
||||
});
|
||||
|
||||
it("maps image/webp to webp", () => {
|
||||
expect(mimeTypeToExtension("image/webp")).toBe("webp");
|
||||
});
|
||||
|
||||
it("returns jpg for unknown mime types", () => {
|
||||
expect(mimeTypeToExtension("image/bmp")).toBe("jpg");
|
||||
expect(mimeTypeToExtension("unknown/type")).toBe("jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizeImageServer", () => {
|
||||
it("resizes image to specified dimensions", async () => {
|
||||
// Re-import after mock is set up
|
||||
const { resizeImageServer } = await import("./image-processing-server");
|
||||
const buffer = Buffer.from("test-image-data");
|
||||
Reference in New Issue
Block a user