localgreenchain/__tests__/lib/vertical-farming/controller.test.ts
Claude b8d2d5be5f
Add comprehensive testing suite for transport, demand, and vertical farming systems
- Set up Jest testing framework with TypeScript support
- Add unit tests for TransportChain blockchain (tracker, carbon, types)
- Add unit tests for DemandForecaster (forecaster, aggregation, recommendations)
- Add unit tests for VerticalFarmController (controller, recipes, environment)
- Add API tests for transport, demand, and vertical-farm endpoints
- Add integration tests for full lifecycle workflows:
  - Seed-to-seed lifecycle
  - Demand-to-harvest flow
  - VF batch lifecycle
2025-11-22 18:47:04 +00:00

530 lines
15 KiB
TypeScript

/**
* 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',
};
}