- 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
558 lines
17 KiB
TypeScript
558 lines
17 KiB
TypeScript
/**
|
|
* Vertical Farm Batch Lifecycle Integration Tests
|
|
* Tests complete vertical farm batch from start to harvest
|
|
*/
|
|
|
|
import {
|
|
VerticalFarmController,
|
|
} from '../../lib/vertical-farming/controller';
|
|
import { TransportChain, setTransportChain } from '../../lib/transport/tracker';
|
|
import {
|
|
VerticalFarm,
|
|
GrowingZone,
|
|
ZoneEnvironmentReadings,
|
|
CropBatch,
|
|
} from '../../lib/vertical-farming/types';
|
|
import {
|
|
SeedAcquisitionEvent,
|
|
PlantingEvent,
|
|
HarvestEvent,
|
|
TransportLocation,
|
|
} from '../../lib/transport/types';
|
|
|
|
describe('Vertical Farm Batch Lifecycle', () => {
|
|
let controller: VerticalFarmController;
|
|
let chain: TransportChain;
|
|
|
|
beforeEach(() => {
|
|
controller = new VerticalFarmController();
|
|
chain = new TransportChain(1);
|
|
setTransportChain(chain);
|
|
});
|
|
|
|
describe('Complete Batch Lifecycle', () => {
|
|
it('should complete full batch lifecycle from planting to harvest', () => {
|
|
// Step 1: Register farm
|
|
const farm = createVerticalFarm('vf-lifecycle-001');
|
|
controller.registerFarm(farm);
|
|
|
|
// Step 2: Select recipe and start batch
|
|
const recipes = controller.getRecipes();
|
|
const lettuceRecipe = recipes.find(r => r.cropType === 'lettuce')!;
|
|
|
|
const batch = controller.startCropBatch(
|
|
'vf-lifecycle-001',
|
|
'zone-001',
|
|
lettuceRecipe.id,
|
|
'seed-batch-vf-001',
|
|
100
|
|
);
|
|
|
|
expect(batch.status).toBe('germinating');
|
|
expect(batch.plantCount).toBe(100);
|
|
expect(batch.currentStage).toBe(lettuceRecipe.stages[0].name);
|
|
|
|
// Step 3: Verify zone is updated
|
|
const farmAfterPlanting = controller.getFarm('vf-lifecycle-001')!;
|
|
const zone = farmAfterPlanting.zones.find(z => z.id === 'zone-001')!;
|
|
|
|
expect(zone.status).toBe('planted');
|
|
expect(zone.currentCrop).toBe('lettuce');
|
|
expect(zone.plantIds.length).toBe(100);
|
|
|
|
// Step 4: Record environment readings (simulate daily monitoring)
|
|
for (let day = 0; day < 5; day++) {
|
|
const readings = createGoodReadings();
|
|
const alerts = controller.recordEnvironment('zone-001', readings);
|
|
expect(alerts.length).toBe(0); // No alerts for good readings
|
|
}
|
|
|
|
// Step 5: Update batch progress
|
|
const updatedBatch = controller.updateBatchProgress(batch.id);
|
|
expect(updatedBatch.currentDay).toBeGreaterThanOrEqual(0);
|
|
|
|
// Step 6: Complete harvest
|
|
const completedBatch = controller.completeHarvest(batch.id, 18.0, 'A');
|
|
|
|
expect(completedBatch.status).toBe('completed');
|
|
expect(completedBatch.actualYieldKg).toBe(18.0);
|
|
expect(completedBatch.qualityGrade).toBe('A');
|
|
|
|
// Step 7: Verify zone is cleared
|
|
const farmAfterHarvest = controller.getFarm('vf-lifecycle-001')!;
|
|
const zoneAfterHarvest = farmAfterHarvest.zones.find(z => z.id === 'zone-001')!;
|
|
|
|
expect(zoneAfterHarvest.status).toBe('cleaning');
|
|
expect(zoneAfterHarvest.currentCrop).toBe('');
|
|
expect(zoneAfterHarvest.plantIds.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Environment Monitoring During Growth', () => {
|
|
it('should track health score throughout growth', () => {
|
|
const farm = createVerticalFarm('vf-health-001');
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
const batch = controller.startCropBatch(
|
|
'vf-health-001',
|
|
'zone-001',
|
|
recipes[0].id,
|
|
'seed-batch-001',
|
|
100
|
|
);
|
|
|
|
const initialHealth = batch.healthScore;
|
|
expect(initialHealth).toBe(100);
|
|
|
|
// Good readings - health should stay high
|
|
for (let i = 0; i < 3; i++) {
|
|
controller.recordEnvironment('zone-001', createGoodReadings());
|
|
}
|
|
expect(batch.healthScore).toBe(100);
|
|
|
|
// Bad readings - health should decrease
|
|
controller.recordEnvironment('zone-001', createBadReadings());
|
|
expect(batch.healthScore).toBeLessThan(100);
|
|
|
|
// Critical readings - health should decrease more
|
|
controller.recordEnvironment('zone-001', createCriticalReadings());
|
|
expect(batch.healthScore).toBeLessThan(95);
|
|
});
|
|
|
|
it('should log environment readings to batch', () => {
|
|
const farm = createVerticalFarm('vf-log-001');
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
const batch = controller.startCropBatch(
|
|
'vf-log-001',
|
|
'zone-001',
|
|
recipes[0].id,
|
|
'seed-batch-001',
|
|
100
|
|
);
|
|
|
|
// Record multiple readings
|
|
for (let i = 0; i < 5; i++) {
|
|
controller.recordEnvironment('zone-001', createGoodReadings());
|
|
}
|
|
|
|
expect(batch.environmentLog.length).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('Multi-Zone Operations', () => {
|
|
it('should manage multiple batches across zones', () => {
|
|
const farm = createVerticalFarm('vf-multi-001');
|
|
farm.zones = [
|
|
createGrowingZone('zone-001', 'Zone A', 1),
|
|
createGrowingZone('zone-002', 'Zone B', 1),
|
|
createGrowingZone('zone-003', 'Zone C', 2),
|
|
];
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
const lettuceRecipe = recipes.find(r => r.cropType === 'lettuce')!;
|
|
const basilRecipe = recipes.find(r => r.cropType === 'basil')!;
|
|
const microgreensRecipe = recipes.find(r => r.cropType === 'microgreens')!;
|
|
|
|
// Start different batches in different zones
|
|
const batch1 = controller.startCropBatch(
|
|
'vf-multi-001', 'zone-001', lettuceRecipe.id, 'seed-001', 100
|
|
);
|
|
const batch2 = controller.startCropBatch(
|
|
'vf-multi-001', 'zone-002', basilRecipe.id, 'seed-002', 50
|
|
);
|
|
const batch3 = controller.startCropBatch(
|
|
'vf-multi-001', 'zone-003', microgreensRecipe.id, 'seed-003', 200
|
|
);
|
|
|
|
expect(batch1.cropType).toBe('lettuce');
|
|
expect(batch2.cropType).toBe('basil');
|
|
expect(batch3.cropType).toBe('microgreens');
|
|
|
|
// Verify zones have correct crops
|
|
const farmAfter = controller.getFarm('vf-multi-001')!;
|
|
expect(farmAfter.zones.find(z => z.id === 'zone-001')!.currentCrop).toBe('lettuce');
|
|
expect(farmAfter.zones.find(z => z.id === 'zone-002')!.currentCrop).toBe('basil');
|
|
expect(farmAfter.zones.find(z => z.id === 'zone-003')!.currentCrop).toBe('microgreens');
|
|
});
|
|
});
|
|
|
|
describe('Analytics Generation', () => {
|
|
it('should generate analytics after batch completion', () => {
|
|
const farm = createVerticalFarm('vf-analytics-001');
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
|
|
// Complete multiple batches
|
|
for (let i = 0; i < 3; i++) {
|
|
const batch = controller.startCropBatch(
|
|
'vf-analytics-001',
|
|
'zone-001',
|
|
recipes[0].id,
|
|
`seed-batch-${i}`,
|
|
100
|
|
);
|
|
controller.completeHarvest(batch.id, 15 + i, 'A');
|
|
}
|
|
|
|
const analytics = controller.generateAnalytics('vf-analytics-001', 30);
|
|
|
|
expect(analytics.cropCyclesCompleted).toBe(3);
|
|
expect(analytics.totalYieldKg).toBeGreaterThan(0);
|
|
expect(analytics.gradeAPercent).toBe(100);
|
|
});
|
|
|
|
it('should calculate efficiency metrics', () => {
|
|
const farm = createVerticalFarm('vf-efficiency-001');
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
const batch = controller.startCropBatch(
|
|
'vf-efficiency-001',
|
|
'zone-001',
|
|
recipes[0].id,
|
|
'seed-batch-001',
|
|
100
|
|
);
|
|
|
|
controller.completeHarvest(batch.id, 18.0, 'A');
|
|
|
|
const analytics = controller.generateAnalytics('vf-efficiency-001', 30);
|
|
|
|
expect(analytics.yieldPerSqmPerYear).toBeGreaterThan(0);
|
|
expect(analytics.averageQualityScore).toBeGreaterThan(0);
|
|
expect(analytics.cropSuccessRate).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('Integration with Transport Chain', () => {
|
|
it('should track VF produce through transport chain', () => {
|
|
const farm = createVerticalFarm('vf-transport-001');
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
const batch = controller.startCropBatch(
|
|
'vf-transport-001',
|
|
'zone-001',
|
|
recipes[0].id,
|
|
'seed-transport-001',
|
|
100
|
|
);
|
|
|
|
// Record seed acquisition in transport chain
|
|
const seedEvent: SeedAcquisitionEvent = {
|
|
id: 'vf-transport-seed-001',
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'seed_acquisition',
|
|
fromLocation: createLocation('seed_bank', 'Seed Supplier'),
|
|
toLocation: createLocation('vertical_farm', 'VF Facility'),
|
|
distanceKm: 20,
|
|
durationMinutes: 30,
|
|
transportMethod: 'electric_vehicle',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'seed-supplier',
|
|
receiverId: 'vf-operator',
|
|
status: 'verified',
|
|
seedBatchId: 'seed-transport-001',
|
|
sourceType: 'purchase',
|
|
species: recipes[0].cropType,
|
|
quantity: 100,
|
|
quantityUnit: 'seeds',
|
|
generation: 1,
|
|
certifications: ['organic'],
|
|
};
|
|
|
|
chain.recordEvent(seedEvent);
|
|
|
|
// Complete VF harvest
|
|
controller.completeHarvest(batch.id, 18.0, 'A');
|
|
|
|
// Record harvest in transport chain
|
|
const harvestEvent: HarvestEvent = {
|
|
id: 'vf-transport-harvest-001',
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'harvest',
|
|
fromLocation: createLocation('vertical_farm', 'VF Facility'),
|
|
toLocation: createLocation('warehouse', 'Distribution Center'),
|
|
distanceKm: 10,
|
|
durationMinutes: 20,
|
|
transportMethod: 'electric_truck',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'vf-operator',
|
|
receiverId: 'distributor',
|
|
status: 'verified',
|
|
plantIds: batch.plantIds.slice(0, 10),
|
|
harvestBatchId: batch.id,
|
|
harvestType: 'full',
|
|
produceType: recipes[0].cropType,
|
|
grossWeight: 20,
|
|
netWeight: 18,
|
|
weightUnit: 'kg',
|
|
qualityGrade: 'A',
|
|
packagingType: 'sustainable_packaging',
|
|
temperatureRequired: { min: 2, max: 8, optimal: 4, unit: 'celsius' },
|
|
shelfLifeHours: 168,
|
|
seedsSaved: false,
|
|
};
|
|
|
|
chain.recordEvent(harvestEvent);
|
|
|
|
// Verify chain integrity
|
|
expect(chain.isChainValid()).toBe(true);
|
|
|
|
// Calculate environmental impact
|
|
const impact = chain.getEnvironmentalImpact('vf-operator');
|
|
expect(impact.totalFoodMiles).toBeLessThan(50); // Very low for VF
|
|
});
|
|
});
|
|
|
|
describe('Recipe Stage Transitions', () => {
|
|
it('should update environment targets based on stage', () => {
|
|
const farm = createVerticalFarm('vf-stage-001');
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
const recipe = recipes[0];
|
|
|
|
const batch = controller.startCropBatch(
|
|
'vf-stage-001',
|
|
'zone-001',
|
|
recipe.id,
|
|
'seed-stage-001',
|
|
100
|
|
);
|
|
|
|
// Initial stage should be first stage
|
|
expect(batch.currentStage).toBe(recipe.stages[0].name);
|
|
|
|
// Get zone and check targets match first stage
|
|
const farmAfter = controller.getFarm('vf-stage-001')!;
|
|
const zone = farmAfter.zones.find(z => z.id === 'zone-001')!;
|
|
const firstStage = recipe.stages[0];
|
|
|
|
expect(zone.environmentTargets.temperatureC.target).toBe(firstStage.temperature.day);
|
|
});
|
|
});
|
|
|
|
describe('Batch Failure Handling', () => {
|
|
it('should track issues that affect batch', () => {
|
|
const farm = createVerticalFarm('vf-issue-001');
|
|
controller.registerFarm(farm);
|
|
|
|
const recipes = controller.getRecipes();
|
|
const batch = controller.startCropBatch(
|
|
'vf-issue-001',
|
|
'zone-001',
|
|
recipes[0].id,
|
|
'seed-issue-001',
|
|
100
|
|
);
|
|
|
|
// Simulate multiple environment issues
|
|
for (let i = 0; i < 20; i++) {
|
|
controller.recordEnvironment('zone-001', createCriticalReadings());
|
|
}
|
|
|
|
// Health should be significantly reduced
|
|
expect(batch.healthScore).toBeLessThan(50);
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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'],
|
|
buildingType: 'warehouse',
|
|
insulation: 'high_efficiency',
|
|
},
|
|
zones: [createGrowingZone('zone-001', 'Zone A', 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',
|
|
};
|
|
}
|
|
|
|
function createLocation(type: string, name: string): TransportLocation {
|
|
return {
|
|
latitude: 40.7 + Math.random() * 0.1,
|
|
longitude: -74.0 + Math.random() * 0.1,
|
|
locationType: type as any,
|
|
facilityName: name,
|
|
};
|
|
}
|
|
|
|
function createGoodReadings(): ZoneEnvironmentReadings {
|
|
return {
|
|
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: [],
|
|
};
|
|
}
|
|
|
|
function createBadReadings(): ZoneEnvironmentReadings {
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
temperatureC: 28, // Too high
|
|
humidityPercent: 55, // Too low
|
|
co2Ppm: 1000,
|
|
vpd: 1.0,
|
|
ppfd: 300,
|
|
dli: 17,
|
|
waterTempC: 20,
|
|
ec: 1.5,
|
|
ph: 6.0,
|
|
dissolvedOxygenPpm: 8,
|
|
airflowMs: 0.5,
|
|
alerts: [],
|
|
};
|
|
}
|
|
|
|
function createCriticalReadings(): ZoneEnvironmentReadings {
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
temperatureC: 35, // Critical high
|
|
humidityPercent: 40, // Critical low
|
|
co2Ppm: 500, // Low
|
|
vpd: 2.5,
|
|
ppfd: 100, // Low
|
|
dli: 5,
|
|
waterTempC: 30, // Too high
|
|
ec: 0.5, // Too low
|
|
ph: 7.5, // Too high
|
|
dissolvedOxygenPpm: 4,
|
|
airflowMs: 0.1,
|
|
alerts: [],
|
|
};
|
|
}
|