- Next.js 16 App Router project with Tailwind CSS - Plant disease knowledge base (93 diseases, 25 plants) - Image upload with client+server preprocessing - ML inference pipeline with mock/demo fallback - Responsive results page with disease cards and treatment - Full test suite (285 passing tests)
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
getPlantById,
|
|
getDiseaseById,
|
|
getDiseasesByPlantId,
|
|
getPlantWithDiseases,
|
|
getDiseaseWithPlant,
|
|
getLookalikeDiseases,
|
|
searchPlants,
|
|
searchDiseases,
|
|
listPlants,
|
|
listDiseases,
|
|
validateKnowledgeBase,
|
|
plants,
|
|
diseases,
|
|
} from "@/lib/api/diseases";
|
|
|
|
describe("Knowledge Base Data", () => {
|
|
it("has ≥20 plants", () => {
|
|
expect(plants.length).toBeGreaterThanOrEqual(20);
|
|
});
|
|
|
|
it("has ≥80 diseases", () => {
|
|
expect(diseases.length).toBeGreaterThanOrEqual(80);
|
|
});
|
|
|
|
it("passes cross-reference validation (no errors)", () => {
|
|
const errors = validateKnowledgeBase();
|
|
expect(errors).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("getPlantById", () => {
|
|
it("returns plant for known ID", () => {
|
|
const plant = getPlantById("tomato");
|
|
expect(plant).toBeDefined();
|
|
expect(plant!.commonName).toBe("Tomato");
|
|
expect(plant!.scientificName).toBe("Solanum lycopersicum");
|
|
});
|
|
|
|
it("returns undefined for unknown ID", () => {
|
|
expect(getPlantById("nonexistent")).toBeUndefined();
|
|
});
|
|
|
|
it("is case-insensitive", () => {
|
|
const plant = getPlantById("TOMATO");
|
|
expect(plant).toBeDefined();
|
|
expect(plant!.commonName).toBe("Tomato");
|
|
});
|
|
});
|
|
|
|
describe("getDiseaseById", () => {
|
|
it("returns disease for known ID", () => {
|
|
const disease = getDiseaseById("early-blight");
|
|
expect(disease).toBeDefined();
|
|
expect(disease!.name).toBe("Early Blight");
|
|
expect(disease!.plantId).toBe("tomato");
|
|
});
|
|
|
|
it("returns undefined for unknown ID", () => {
|
|
expect(getDiseaseById("nonexistent")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("getDiseasesByPlantId", () => {
|
|
it("returns diseases for tomato", () => {
|
|
const diseases = getDiseasesByPlantId("tomato");
|
|
expect(diseases.length).toBeGreaterThanOrEqual(3);
|
|
expect(diseases.every((d) => d.plantId === "tomato")).toBe(true);
|
|
});
|
|
|
|
it("returns empty array for plant with no diseases", () => {
|
|
const diseases = getDiseasesByPlantId("nonexistent");
|
|
expect(diseases).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("getPlantWithDiseases", () => {
|
|
it("returns plant with diseases for known ID", () => {
|
|
const result = getPlantWithDiseases("tomato");
|
|
expect(result).toBeDefined();
|
|
expect(result!.plant.id).toBe("tomato");
|
|
expect(result!.diseases.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it("returns undefined for unknown ID", () => {
|
|
expect(getPlantWithDiseases("nonexistent")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("getDiseaseWithPlant", () => {
|
|
it("returns disease with plant for known ID", () => {
|
|
const result = getDiseaseWithPlant("early-blight");
|
|
expect(result).toBeDefined();
|
|
expect(result!.disease.id).toBe("early-blight");
|
|
expect(result!.plant.id).toBe("tomato");
|
|
});
|
|
|
|
it("returns undefined for unknown ID", () => {
|
|
expect(getDiseaseWithPlant("nonexistent")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("getLookalikeDiseases", () => {
|
|
it("returns lookalike diseases for early blight", () => {
|
|
const lookalikes = getLookalikeDiseases("early-blight");
|
|
expect(lookalikes.length).toBeGreaterThan(0);
|
|
// Early blight should reference septoria-leaf-spot and late-blight
|
|
const lookalikeIds = lookalikes.map((d) => d.id);
|
|
expect(lookalikeIds).toContain("septoria-leaf-spot");
|
|
expect(lookalikeIds).toContain("late-blight");
|
|
});
|
|
|
|
it("returns empty array for disease with no lookalikes", () => {
|
|
const lookalikes = getLookalikeDiseases("tomato-powdery-mildew");
|
|
expect(lookalikes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("searchPlants", () => {
|
|
it("returns all plants for empty search", () => {
|
|
const results = searchPlants("");
|
|
expect(results).toEqual(plants);
|
|
});
|
|
|
|
it("finds tomato by common name", () => {
|
|
const results = searchPlants("tomato");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.some((p) => p.id === "tomato")).toBe(true);
|
|
});
|
|
|
|
it("finds plants by scientific name", () => {
|
|
const results = searchPlants("Solanum");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((p) => p.scientificName.includes("Solanum"))).toBe(true);
|
|
});
|
|
|
|
it("finds plants by family", () => {
|
|
const results = searchPlants("Lamiaceae");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((p) => p.family === "Lamiaceae")).toBe(true);
|
|
});
|
|
|
|
it("finds plants by category", () => {
|
|
const results = searchPlants("houseplant");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((p) => p.category === "houseplant")).toBe(true);
|
|
});
|
|
|
|
it("returns empty array for no matches", () => {
|
|
const results = searchPlants("xyznonexistent123");
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("searchDiseases", () => {
|
|
it("returns all diseases for empty search", () => {
|
|
const results = searchDiseases("");
|
|
expect(results).toEqual(diseases);
|
|
});
|
|
|
|
it("finds diseases by name", () => {
|
|
const results = searchDiseases("blight");
|
|
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("finds diseases by scientific name", () => {
|
|
const results = searchDiseases("Alternaria");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("finds diseases by description content", () => {
|
|
const results = searchDiseases("calcium");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("finds diseases by symptom text", () => {
|
|
const results = searchDiseases("powdery");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("returns empty array for no matches", () => {
|
|
const results = searchDiseases("xyznonexistent123");
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("listPlants", () => {
|
|
it("returns all plants with no filters", () => {
|
|
const results = listPlants();
|
|
expect(results).toEqual(plants);
|
|
});
|
|
|
|
it("filters by category", () => {
|
|
const results = listPlants({ category: "vegetable" });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((p) => p.category === "vegetable")).toBe(true);
|
|
});
|
|
|
|
it("combines search and category filter", () => {
|
|
const results = listPlants({ search: "leaf", category: "houseplant" });
|
|
expect(results.every((p) => p.category === "houseplant")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("listDiseases", () => {
|
|
it("returns all diseases with no filters", () => {
|
|
const results = listDiseases();
|
|
expect(results).toEqual(diseases);
|
|
});
|
|
|
|
it("filters by plantId", () => {
|
|
const results = listDiseases({ plantId: "tomato" });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((d) => d.plantId === "tomato")).toBe(true);
|
|
});
|
|
|
|
it("filters by causalAgentType", () => {
|
|
const results = listDiseases({ causalAgentType: "fungal" });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((d) => d.causalAgentType === "fungal")).toBe(true);
|
|
});
|
|
|
|
it("filters by severity", () => {
|
|
const results = listDiseases({ severity: "critical" });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((d) => d.severity === "critical")).toBe(true);
|
|
});
|
|
|
|
it("combines plantId and search filters", () => {
|
|
const results = listDiseases({ plantId: "tomato", search: "blight" });
|
|
expect(results.every((d) => d.plantId === "tomato")).toBe(true);
|
|
expect(results.every((d) => d.name.toLowerCase().includes("blight") || d.description.toLowerCase().includes("blight") || d.symptoms.some((s) => s.toLowerCase().includes("blight")))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("validateKnowledgeBase", () => {
|
|
it("returns no errors for valid data", () => {
|
|
const errors = validateKnowledgeBase();
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
it("detects invalid plant references", () => {
|
|
// Temporarily modify a disease to have invalid plantId
|
|
const original = diseases[0].plantId;
|
|
diseases[0].plantId = "nonexistent-plant";
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].plantId = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("nonexistent-plant"))).toBe(true);
|
|
});
|
|
|
|
it("detects invalid causalAgentType", () => {
|
|
const original = diseases[0].causalAgentType;
|
|
(diseases[0] as any).causalAgentType = "invalid-type";
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].causalAgentType = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("invalid-type"))).toBe(true);
|
|
});
|
|
|
|
it("detects invalid severity", () => {
|
|
const original = diseases[0].severity;
|
|
(diseases[0] as any).severity = "invalid-severity";
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].severity = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("invalid-severity"))).toBe(true);
|
|
});
|
|
|
|
it("detects too few symptoms", () => {
|
|
const original = [...diseases[0].symptoms];
|
|
diseases[0].symptoms = ["only one"];
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].symptoms = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("fewer than 3 symptoms"))).toBe(true);
|
|
});
|
|
|
|
it("detects too few causes", () => {
|
|
const original = [...diseases[0].causes];
|
|
diseases[0].causes = ["only one"];
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].causes = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("fewer than 2 causes"))).toBe(true);
|
|
});
|
|
|
|
it("detects too few treatments", () => {
|
|
const original = [...diseases[0].treatment];
|
|
diseases[0].treatment = ["one", "two"];
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].treatment = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("fewer than 3 treatment"))).toBe(true);
|
|
});
|
|
|
|
it("detects too few prevention tips", () => {
|
|
const original = [...diseases[0].prevention];
|
|
diseases[0].prevention = ["only one"];
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].prevention = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("fewer than 2 prevention"))).toBe(true);
|
|
});
|
|
|
|
it("detects invalid lookalike references", () => {
|
|
const original = [...diseases[0].lookalikeDiseaseIds];
|
|
diseases[0].lookalikeDiseaseIds = ["nonexistent-disease"];
|
|
const errors = validateKnowledgeBase();
|
|
diseases[0].lookalikeDiseaseIds = original;
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some((e) => e.includes("nonexistent-disease"))).toBe(true);
|
|
});
|
|
|
|
it("detects non-bidirectional lookalike references", () => {
|
|
// early-blight references septoria-leaf-spot and late-blight
|
|
// If we remove early-blight from septoria's lookalikes, it should flag
|
|
const septoria = diseases.find((d) => d.id === "septoria-leaf-spot");
|
|
if (septoria) {
|
|
const original = [...septoria.lookalikeDiseaseIds];
|
|
septoria.lookalikeDiseaseIds = septoria.lookalikeDiseaseIds.filter(
|
|
(id) => id !== "early-blight"
|
|
);
|
|
const errors = validateKnowledgeBase();
|
|
septoria.lookalikeDiseaseIds = original;
|
|
expect(errors.some((e) => e.includes("not bidirectional"))).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Data quality checks", () => {
|
|
it("every disease has ≥3 symptoms", () => {
|
|
for (const d of diseases) {
|
|
expect(d.symptoms.length).toBeGreaterThanOrEqual(3);
|
|
}
|
|
});
|
|
|
|
it("every disease has ≥2 causes", () => {
|
|
for (const d of diseases) {
|
|
expect(d.causes.length).toBeGreaterThanOrEqual(2);
|
|
}
|
|
});
|
|
|
|
it("every disease has ≥3 treatment steps", () => {
|
|
for (const d of diseases) {
|
|
expect(d.treatment.length).toBeGreaterThanOrEqual(3);
|
|
}
|
|
});
|
|
|
|
it("every disease has ≥2 prevention tips", () => {
|
|
for (const d of diseases) {
|
|
expect(d.prevention.length).toBeGreaterThanOrEqual(2);
|
|
}
|
|
});
|
|
|
|
it("every disease references a valid plant ID", () => {
|
|
const plantIds = new Set(plants.map((p) => p.id));
|
|
for (const d of diseases) {
|
|
expect(plantIds.has(d.plantId)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("every disease has valid causalAgentType enum value", () => {
|
|
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
|
|
for (const d of diseases) {
|
|
expect(validTypes).toContain(d.causalAgentType);
|
|
}
|
|
});
|
|
|
|
it("every disease has valid severity enum value", () => {
|
|
const validSeverities = ["low", "moderate", "high", "critical"];
|
|
for (const d of diseases) {
|
|
expect(validSeverities).toContain(d.severity);
|
|
}
|
|
});
|
|
|
|
it("all lookalike references are valid disease IDs", () => {
|
|
const diseaseIds = new Set(diseases.map((d) => d.id));
|
|
for (const d of diseases) {
|
|
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
|
expect(diseaseIds.has(lookalikeId)).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("includes both biotic and abiotic disease types", () => {
|
|
const types = new Set(diseases.map((d) => d.causalAgentType));
|
|
expect(types.has("fungal")).toBe(true);
|
|
expect(types.has("bacterial")).toBe(true);
|
|
expect(types.has("viral")).toBe(true);
|
|
expect(types.has("environmental")).toBe(true);
|
|
});
|
|
|
|
it("has multiple plants represented", () => {
|
|
const plantIds = new Set(diseases.map((d) => d.plantId));
|
|
expect(plantIds.size).toBeGreaterThanOrEqual(20);
|
|
});
|
|
});
|