/** * VerticalFarmController Tests * Tests for the vertical farm management system */ import { VerticalFarmController, getVerticalFarmController, } from '../../../lib/vertical-farming/controller'; import { VerticalFarm, GrowingZone, CropBatch, GrowingRecipe, ZoneEnvironmentReadings, } from '../../../lib/vertical-farming/types'; describe('VerticalFarmController', () => { let controller: VerticalFarmController; beforeEach(() => { controller = new VerticalFarmController(); }); describe('Initialization', () => { it('should initialize with default recipes', () => { const recipes = controller.getRecipes(); expect(recipes.length).toBeGreaterThan(0); }); it('should have lettuce recipe', () => { const recipes = controller.getRecipes(); const lettuceRecipe = recipes.find(r => r.cropType === 'lettuce'); expect(lettuceRecipe).toBeDefined(); }); it('should have basil recipe', () => { const recipes = controller.getRecipes(); const basilRecipe = recipes.find(r => r.cropType === 'basil'); expect(basilRecipe).toBeDefined(); }); it('should have microgreens recipe', () => { const recipes = controller.getRecipes(); const microgreensRecipe = recipes.find(r => r.cropType === 'microgreens'); expect(microgreensRecipe).toBeDefined(); }); }); describe('Farm Registration', () => { it('should register a vertical farm', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const retrieved = controller.getFarm('farm-001'); expect(retrieved).toBeDefined(); expect(retrieved?.name).toBe('Test Vertical Farm'); }); it('should track multiple farms', () => { controller.registerFarm(createVerticalFarm('farm-001')); controller.registerFarm(createVerticalFarm('farm-002')); expect(controller.getFarm('farm-001')).toBeDefined(); expect(controller.getFarm('farm-002')).toBeDefined(); }); it('should return undefined for unknown farm', () => { expect(controller.getFarm('unknown-farm')).toBeUndefined(); }); }); describe('Crop Batch Management', () => { it('should start crop batch with recipe', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const lettuceRecipe = recipes.find(r => r.cropType === 'lettuce')!; const batch = controller.startCropBatch( 'farm-001', 'zone-001', lettuceRecipe.id, 'seed-batch-001', 100 ); expect(batch.id).toBeDefined(); expect(batch.farmId).toBe('farm-001'); expect(batch.zoneId).toBe('zone-001'); expect(batch.plantCount).toBe(100); expect(batch.status).toBe('germinating'); }); it('should generate plant IDs for batch', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const recipe = recipes[0]; const batch = controller.startCropBatch( 'farm-001', 'zone-001', recipe.id, 'seed-batch-001', 50 ); expect(batch.plantIds.length).toBe(50); expect(batch.plantIds[0]).toContain('plant-'); }); it('should calculate expected yield', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const lettuceRecipe = recipes.find(r => r.cropType === 'lettuce')!; const batch = controller.startCropBatch( 'farm-001', 'zone-001', lettuceRecipe.id, 'seed-batch-001', 100 ); // Expected yield = expectedYieldGrams * plantCount / 1000 const expectedYield = (lettuceRecipe.expectedYieldGrams * 100) / 1000; expect(batch.expectedYieldKg).toBe(expectedYield); }); it('should update zone status when starting batch', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const batch = controller.startCropBatch( 'farm-001', 'zone-001', recipes[0].id, 'seed-batch-001', 100 ); const updatedFarm = controller.getFarm('farm-001'); const zone = updatedFarm?.zones.find(z => z.id === 'zone-001'); expect(zone?.status).toBe('planted'); expect(zone?.currentCrop).toBe(recipes[0].cropType); expect(zone?.plantIds.length).toBe(100); }); it('should throw error for unknown farm', () => { expect(() => { controller.startCropBatch( 'unknown-farm', 'zone-001', 'recipe-001', 'seed-001', 100 ); }).toThrow('Farm unknown-farm not found'); }); it('should throw error for unknown zone', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); expect(() => { controller.startCropBatch( 'farm-001', 'unknown-zone', 'recipe-001', 'seed-001', 100 ); }).toThrow('Zone unknown-zone not found'); }); it('should throw error for unknown recipe', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); expect(() => { controller.startCropBatch( 'farm-001', 'zone-001', 'unknown-recipe', 'seed-001', 100 ); }).toThrow('Recipe unknown-recipe not found'); }); }); describe('Batch Progress', () => { it('should update batch progress', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const batch = controller.startCropBatch( 'farm-001', 'zone-001', recipes[0].id, 'seed-batch-001', 100 ); const updated = controller.updateBatchProgress(batch.id); expect(updated.currentDay).toBeGreaterThanOrEqual(0); }); it('should update current stage based on day', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const batch = controller.startCropBatch( 'farm-001', 'zone-001', recipes[0].id, 'seed-batch-001', 100 ); // Initial stage should be first stage expect(batch.currentStage).toBe(recipes[0].stages[0].name); }); it('should throw error for unknown batch', () => { expect(() => { controller.updateBatchProgress('unknown-batch'); }).toThrow('Batch unknown-batch not found'); }); }); describe('Harvest Completion', () => { it('should complete harvest and record yield', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const batch = controller.startCropBatch( 'farm-001', 'zone-001', recipes[0].id, 'seed-batch-001', 100 ); const completed = controller.completeHarvest(batch.id, 15.5, 'A'); expect(completed.status).toBe('completed'); expect(completed.actualYieldKg).toBe(15.5); expect(completed.qualityGrade).toBe('A'); expect(completed.actualHarvestDate).toBeDefined(); }); it('should update zone after harvest', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const batch = controller.startCropBatch( 'farm-001', 'zone-001', recipes[0].id, 'seed-batch-001', 100 ); controller.completeHarvest(batch.id, 15.5, 'A'); const updatedFarm = controller.getFarm('farm-001'); const zone = updatedFarm?.zones.find(z => z.id === 'zone-001'); expect(zone?.status).toBe('cleaning'); expect(zone?.currentCrop).toBe(''); expect(zone?.plantIds.length).toBe(0); }); it('should throw error for unknown batch', () => { expect(() => { controller.completeHarvest('unknown-batch', 10, 'A'); }).toThrow('Batch unknown-batch not found'); }); }); describe('Recipe Management', () => { it('should add custom recipe', () => { const customRecipe: GrowingRecipe = { id: 'recipe-custom-spinach', name: 'Custom Spinach Recipe', cropType: 'spinach', version: '1.0', stages: [ { name: 'Germination', daysStart: 0, daysEnd: 5, temperature: { day: 20, night: 18 }, humidity: { day: 80, night: 85 }, co2Ppm: 800, lightHours: 16, lightPpfd: 150, nutrientRecipeId: 'nutrient-seedling', targetEc: 0.8, targetPh: 6.0, actions: [], }, ], expectedDays: 40, expectedYieldGrams: 100, expectedYieldPerSqm: 3000, requirements: { positions: 1, zoneType: 'NFT', minimumPpfd: 200, idealTemperatureC: 18, }, source: 'internal', timesUsed: 0, }; controller.addRecipe(customRecipe); const recipes = controller.getRecipes(); const added = recipes.find(r => r.id === 'recipe-custom-spinach'); expect(added).toBeDefined(); expect(added?.name).toBe('Custom Spinach Recipe'); }); it('should track recipe usage', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); const recipe = recipes[0]; const initialUsage = recipe.timesUsed; controller.startCropBatch( 'farm-001', 'zone-001', recipe.id, 'seed-batch-001', 100 ); expect(recipe.timesUsed).toBe(initialUsage + 1); }); }); describe('Singleton', () => { it('should return same instance from getVerticalFarmController', () => { const controller1 = getVerticalFarmController(); const controller2 = getVerticalFarmController(); expect(controller1).toBe(controller2); }); }); describe('Serialization', () => { it('should export to JSON', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const json = controller.toJSON(); expect(json).toHaveProperty('farms'); expect(json).toHaveProperty('recipes'); expect(json).toHaveProperty('batches'); }); it('should import from JSON', () => { const farm = createVerticalFarm('farm-001'); controller.registerFarm(farm); const recipes = controller.getRecipes(); controller.startCropBatch( 'farm-001', 'zone-001', recipes[0].id, 'seed-batch-001', 100 ); const json = controller.toJSON(); const restored = VerticalFarmController.fromJSON(json); expect(restored.getFarm('farm-001')).toBeDefined(); expect(restored.getRecipes().length).toBeGreaterThan(0); }); }); }); // Helper functions function createVerticalFarm(id: string): VerticalFarm { return { id, name: 'Test Vertical Farm', ownerId: 'owner-001', location: { latitude: 40.7128, longitude: -74.006, address: '123 Farm Street', city: 'New York', country: 'USA', timezone: 'America/New_York', }, specs: { totalAreaSqm: 500, growingAreaSqm: 400, numberOfLevels: 4, ceilingHeightM: 3, totalGrowingPositions: 4000, currentActivePlants: 0, powerCapacityKw: 100, waterStorageL: 5000, backupPowerHours: 24, certifications: ['organic', 'gap'], buildingType: 'warehouse', insulation: 'high_efficiency', }, zones: [ createGrowingZone('zone-001', 'Zone A', 1), createGrowingZone('zone-002', 'Zone B', 1), ], environmentalControl: { hvacUnits: [], co2Injection: { type: 'tank', capacityKg: 50, currentLevelKg: 40, injectionRateKgPerHour: 2, status: 'maintaining' }, humidification: { type: 'ultrasonic', capacityLPerHour: 10, status: 'running', currentOutput: 5 }, airCirculation: { fans: [] }, controlMode: 'adaptive', }, irrigationSystem: { type: 'recirculating', freshWaterTankL: 2000, freshWaterLevelL: 1800, nutrientTankL: 500, nutrientLevelL: 450, wasteTankL: 200, wasteLevelL: 50, waterTreatment: { ro: true, uv: true, ozone: false, filtration: '10 micron' }, pumps: [], irrigationSchedule: [], }, lightingSystem: { type: 'LED', fixtures: [], lightSchedules: [], totalWattage: 5000, currentWattage: 3000, efficacyUmolJ: 2.5, }, nutrientSystem: { mixingMethod: 'fully_auto', stockSolutions: [], dosingPumps: [], currentRecipe: { id: 'default', name: 'Default', cropType: 'general', growthStage: 'vegetative', targetEc: 1.5, targetPh: 6.0, ratios: { n: 200, p: 50, k: 200, ca: 200, mg: 50, s: 100, fe: 5, mn: 0.5, zn: 0.3, cu: 0.1, b: 0.5, mo: 0.05 }, dosingRatiosMlPerL: [], }, monitoring: { ec: 1.5, ph: 6.0, lastCalibration: new Date().toISOString(), calibrationDue: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), }, }, automationLevel: 'semi_automated', automationSystems: [], status: 'operational', operationalSince: '2024-01-01', lastMaintenanceDate: new Date().toISOString(), currentCapacityUtilization: 75, averageYieldEfficiency: 85, energyEfficiencyScore: 80, }; } function createGrowingZone(id: string, name: string, level: number): GrowingZone { return { id, name, level, areaSqm: 50, lengthM: 10, widthM: 5, growingMethod: 'NFT', plantPositions: 500, currentCrop: '', plantIds: [], plantingDate: '', expectedHarvestDate: '', environmentTargets: { temperatureC: { min: 18, max: 24, target: 21 }, humidityPercent: { min: 60, max: 80, target: 70 }, co2Ppm: { min: 800, max: 1200, target: 1000 }, lightPpfd: { min: 200, max: 400, target: 300 }, lightHours: 16, nutrientEc: { min: 1.2, max: 1.8, target: 1.5 }, nutrientPh: { min: 5.8, max: 6.2, target: 6.0 }, waterTempC: { min: 18, max: 22, target: 20 }, }, currentEnvironment: { timestamp: new Date().toISOString(), temperatureC: 21, humidityPercent: 70, co2Ppm: 1000, vpd: 1.0, ppfd: 300, dli: 17, waterTempC: 20, ec: 1.5, ph: 6.0, dissolvedOxygenPpm: 8, airflowMs: 0.5, alerts: [], }, status: 'empty', }; }