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
This commit is contained in:
parent
b8a3ebb823
commit
b8d2d5be5f
18 changed files with 7840 additions and 0 deletions
319
__tests__/api/demand.test.ts
Normal file
319
__tests__/api/demand.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* Demand API Tests
|
||||
* Tests for demand-related API endpoints
|
||||
*/
|
||||
|
||||
import { DemandForecaster, getDemandForecaster } from '../../lib/demand/forecaster';
|
||||
import {
|
||||
ConsumerPreference,
|
||||
SupplyCommitment,
|
||||
DemandSignal,
|
||||
PlantingRecommendation,
|
||||
} from '../../lib/demand/types';
|
||||
|
||||
describe('Demand API', () => {
|
||||
let forecaster: DemandForecaster;
|
||||
|
||||
beforeEach(() => {
|
||||
forecaster = new DemandForecaster();
|
||||
});
|
||||
|
||||
describe('POST /api/demand/preferences', () => {
|
||||
it('should register consumer preferences', () => {
|
||||
const preference = createConsumerPreference('api-consumer-001');
|
||||
|
||||
forecaster.registerPreference(preference);
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences.length).toBe(1);
|
||||
expect(json.preferences[0][0]).toBe('api-consumer-001');
|
||||
});
|
||||
|
||||
it('should update existing preferences', () => {
|
||||
const pref1 = createConsumerPreference('api-consumer-001');
|
||||
pref1.householdSize = 2;
|
||||
forecaster.registerPreference(pref1);
|
||||
|
||||
const pref2 = createConsumerPreference('api-consumer-001');
|
||||
pref2.householdSize = 5;
|
||||
forecaster.registerPreference(pref2);
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences.length).toBe(1);
|
||||
expect(json.preferences[0][1].householdSize).toBe(5);
|
||||
});
|
||||
|
||||
it('should validate required fields', () => {
|
||||
const preference = createConsumerPreference('api-consumer-001');
|
||||
|
||||
// All required fields present
|
||||
forecaster.registerPreference(preference);
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/demand/preferences', () => {
|
||||
it('should return preferences for consumer', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-002'));
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/demand/signal', () => {
|
||||
it('should generate demand signal for region', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 50, 'Test Region', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.id).toBeDefined();
|
||||
expect(signal.region.name).toBe('Test Region');
|
||||
expect(signal.demandItems).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include supply status', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 50, 'Test Region', 'summer'
|
||||
);
|
||||
|
||||
expect(['surplus', 'balanced', 'shortage', 'critical']).toContain(signal.supplyStatus);
|
||||
});
|
||||
|
||||
it('should calculate confidence level', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 50, 'Test Region', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.confidenceLevel).toBeGreaterThanOrEqual(0);
|
||||
expect(signal.confidenceLevel).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/demand/recommendations', () => {
|
||||
it('should return planting recommendations', () => {
|
||||
const pref = createConsumerPreference('consumer-001');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-001', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
expect(recommendations.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should include risk assessment', () => {
|
||||
const pref = createConsumerPreference('consumer-001');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-001', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
expect(['low', 'medium', 'high']).toContain(recommendations[0].overallRisk);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/demand/forecast', () => {
|
||||
it('should return demand forecast', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test Region', 'summer');
|
||||
|
||||
const forecast = forecaster.generateForecast('Test Region', 12);
|
||||
|
||||
expect(forecast.id).toBeDefined();
|
||||
expect(forecast.region).toBe('Test Region');
|
||||
expect(forecast.forecasts).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include trend analysis', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test Region', 'summer');
|
||||
|
||||
const forecast = forecaster.generateForecast('Test Region', 12);
|
||||
|
||||
forecast.forecasts.forEach(f => {
|
||||
expect(['increasing', 'stable', 'decreasing']).toContain(f.trend);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/demand/supply', () => {
|
||||
it('should register supply commitment', () => {
|
||||
const commitment = createSupplyCommitment('grower-001', 'lettuce', 50);
|
||||
|
||||
forecaster.registerSupply(commitment);
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.supplyCommitments.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should affect supply gap calculation', () => {
|
||||
const pref = createConsumerPreference('consumer-001');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Generate signal without supply
|
||||
const signal1 = forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
const gap1 = signal1.demandItems.find(i => i.produceType === 'lettuce')?.gapKg || 0;
|
||||
|
||||
// Add supply
|
||||
forecaster.registerSupply(createSupplyCommitment('grower-001', 'lettuce', 5));
|
||||
|
||||
// Generate signal with supply
|
||||
const signal2 = forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
const gap2 = signal2.demandItems.find(i => i.produceType === 'lettuce')?.gapKg || 0;
|
||||
|
||||
expect(gap2).toBeLessThan(gap1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/demand/match', () => {
|
||||
it('should create market match', () => {
|
||||
// This tests the matching logic between supply and demand
|
||||
const pref = createConsumerPreference('consumer-001');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 5, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.registerSupply(createSupplyCommitment('grower-001', 'lettuce', 10));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
|
||||
expect(lettuceItem?.matchedSupply).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle empty region gracefully', () => {
|
||||
// No consumers registered
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 50, 'Empty Region', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.totalConsumers).toBe(0);
|
||||
expect(signal.demandItems.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle invalid coordinates gracefully', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
|
||||
// Very distant coordinates
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
-90, 0, 1, 'Antarctica', 'winter'
|
||||
);
|
||||
|
||||
// Should still return valid signal structure
|
||||
expect(signal.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return consistent signal format', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-001'));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 50, 'Test', 'summer'
|
||||
);
|
||||
|
||||
// Check all required fields
|
||||
expect(signal.id).toBeDefined();
|
||||
expect(signal.timestamp).toBeDefined();
|
||||
expect(signal.region).toBeDefined();
|
||||
expect(signal.periodStart).toBeDefined();
|
||||
expect(signal.periodEnd).toBeDefined();
|
||||
expect(signal.seasonalPeriod).toBeDefined();
|
||||
expect(signal.demandItems).toBeDefined();
|
||||
expect(signal.totalConsumers).toBeDefined();
|
||||
expect(signal.totalWeeklyDemandKg).toBeDefined();
|
||||
expect(signal.confidenceLevel).toBeDefined();
|
||||
expect(signal.currentSupplyKg).toBeDefined();
|
||||
expect(signal.supplyGapKg).toBeDefined();
|
||||
expect(signal.supplyStatus).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function createConsumerPreference(consumerId: string): ConsumerPreference {
|
||||
return {
|
||||
consumerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
maxDeliveryRadiusKm: 25,
|
||||
},
|
||||
dietaryType: ['omnivore'],
|
||||
allergies: [],
|
||||
dislikes: [],
|
||||
preferredCategories: ['leafy_greens'],
|
||||
preferredItems: [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'preferred', weeklyQuantity: 1, seasonalOnly: false },
|
||||
],
|
||||
certificationPreferences: ['organic'],
|
||||
freshnessImportance: 4,
|
||||
priceImportance: 3,
|
||||
sustainabilityImportance: 4,
|
||||
deliveryPreferences: {
|
||||
method: ['home_delivery'],
|
||||
frequency: 'weekly',
|
||||
preferredDays: ['saturday'],
|
||||
},
|
||||
householdSize: 2,
|
||||
weeklyBudget: 100,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
|
||||
function createSupplyCommitment(
|
||||
growerId: string,
|
||||
produceType: string,
|
||||
quantity: number
|
||||
): SupplyCommitment {
|
||||
return {
|
||||
id: `supply-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
growerId,
|
||||
timestamp: new Date().toISOString(),
|
||||
produceType,
|
||||
committedQuantityKg: quantity,
|
||||
availableFrom: new Date().toISOString(),
|
||||
availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
pricePerKg: 5,
|
||||
currency: 'USD',
|
||||
minimumOrderKg: 1,
|
||||
certifications: ['organic'],
|
||||
freshnessGuaranteeHours: 48,
|
||||
deliveryRadiusKm: 50,
|
||||
deliveryMethods: ['grower_delivery'],
|
||||
status: 'available',
|
||||
remainingKg: quantity,
|
||||
};
|
||||
}
|
||||
325
__tests__/api/transport.test.ts
Normal file
325
__tests__/api/transport.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* Transport API Tests
|
||||
* Tests for transport-related API endpoints
|
||||
*
|
||||
* Note: These tests are designed to test API route handlers.
|
||||
* They mock the underlying services and test request/response handling.
|
||||
*/
|
||||
|
||||
import { TransportChain, getTransportChain, setTransportChain } from '../../lib/transport/tracker';
|
||||
import {
|
||||
SeedAcquisitionEvent,
|
||||
PlantingEvent,
|
||||
HarvestEvent,
|
||||
TransportLocation,
|
||||
} from '../../lib/transport/types';
|
||||
|
||||
describe('Transport API', () => {
|
||||
let chain: TransportChain;
|
||||
|
||||
beforeEach(() => {
|
||||
chain = new TransportChain(1);
|
||||
setTransportChain(chain);
|
||||
});
|
||||
|
||||
describe('POST /api/transport/seed-acquisition', () => {
|
||||
it('should record seed acquisition event', () => {
|
||||
const event: SeedAcquisitionEvent = {
|
||||
id: 'api-seed-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 25,
|
||||
durationMinutes: 45,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'seed-bank-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch-001',
|
||||
sourceType: 'seed_bank',
|
||||
species: 'Solanum lycopersicum',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
const block = chain.recordEvent(event);
|
||||
|
||||
expect(block.transportEvent.id).toBe('api-seed-001');
|
||||
expect(block.transportEvent.eventType).toBe('seed_acquisition');
|
||||
});
|
||||
|
||||
it('should reject invalid seed acquisition data', () => {
|
||||
const invalidEvent = {
|
||||
id: 'invalid-001',
|
||||
eventType: 'seed_acquisition',
|
||||
// Missing required fields
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
chain.recordEvent(invalidEvent as any);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/transport/planting', () => {
|
||||
it('should record planting event', () => {
|
||||
const event: PlantingEvent = {
|
||||
id: 'api-planting-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch-001',
|
||||
plantIds: ['plant-001', 'plant-002'],
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: 2,
|
||||
growingEnvironment: 'greenhouse',
|
||||
};
|
||||
|
||||
const block = chain.recordEvent(event);
|
||||
|
||||
expect(block.transportEvent.eventType).toBe('planting');
|
||||
expect((block.transportEvent as PlantingEvent).plantIds.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/transport/harvest', () => {
|
||||
it('should record harvest event', () => {
|
||||
const event: HarvestEvent = {
|
||||
id: 'api-harvest-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'harvest',
|
||||
fromLocation: createLocation('farm'),
|
||||
toLocation: createLocation('warehouse'),
|
||||
distanceKm: 10,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'electric_truck',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'distributor-1',
|
||||
status: 'verified',
|
||||
plantIds: ['plant-001'],
|
||||
harvestBatchId: 'harvest-001',
|
||||
harvestType: 'full',
|
||||
produceType: 'tomatoes',
|
||||
grossWeight: 10,
|
||||
netWeight: 9.5,
|
||||
weightUnit: 'kg',
|
||||
packagingType: 'crates',
|
||||
temperatureRequired: { min: 10, max: 15, optimal: 12, unit: 'celsius' },
|
||||
shelfLifeHours: 168,
|
||||
seedsSaved: false,
|
||||
};
|
||||
|
||||
const block = chain.recordEvent(event);
|
||||
|
||||
expect(block.transportEvent.eventType).toBe('harvest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/transport/journey/[plantId]', () => {
|
||||
it('should return plant journey', () => {
|
||||
const plantId = 'journey-plant-001';
|
||||
|
||||
// Record events for plant
|
||||
const plantingEvent: PlantingEvent = {
|
||||
id: 'journey-planting-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 5,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch-001',
|
||||
plantIds: [plantId],
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: 1,
|
||||
growingEnvironment: 'greenhouse',
|
||||
};
|
||||
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
const journey = chain.getPlantJourney(plantId);
|
||||
|
||||
expect(journey).not.toBeNull();
|
||||
expect(journey?.plantId).toBe(plantId);
|
||||
expect(journey?.events.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null for unknown plant', () => {
|
||||
const journey = chain.getPlantJourney('unknown-plant-id');
|
||||
expect(journey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/transport/footprint/[userId]', () => {
|
||||
it('should return environmental impact', () => {
|
||||
const userId = 'footprint-user-001';
|
||||
|
||||
const event: SeedAcquisitionEvent = {
|
||||
id: 'footprint-event-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 50,
|
||||
durationMinutes: 60,
|
||||
transportMethod: 'diesel_truck',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: userId,
|
||||
receiverId: userId,
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch-footprint',
|
||||
sourceType: 'seed_bank',
|
||||
species: 'Test species',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
chain.recordEvent(event);
|
||||
|
||||
const impact = chain.getEnvironmentalImpact(userId);
|
||||
|
||||
expect(impact.totalFoodMiles).toBe(50);
|
||||
expect(impact.totalCarbonKg).toBeGreaterThan(0);
|
||||
expect(impact.comparisonToConventional).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return zero impact for user with no events', () => {
|
||||
const impact = chain.getEnvironmentalImpact('no-events-user');
|
||||
|
||||
expect(impact.totalFoodMiles).toBe(0);
|
||||
expect(impact.totalCarbonKg).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/transport/verify/[blockHash]', () => {
|
||||
it('should verify chain integrity', () => {
|
||||
chain.recordEvent(createSeedEvent());
|
||||
chain.recordEvent(createSeedEvent());
|
||||
|
||||
const isValid = chain.isChainValid();
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect tampered chain', () => {
|
||||
chain.recordEvent(createSeedEvent());
|
||||
|
||||
// Tamper with block
|
||||
chain.chain[1].transportEvent.distanceKm = 999999;
|
||||
|
||||
const isValid = chain.isChainValid();
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/transport/qr/[id]', () => {
|
||||
it('should generate QR data for plant', () => {
|
||||
const plantId = 'qr-plant-001';
|
||||
|
||||
const plantingEvent: PlantingEvent = {
|
||||
id: 'qr-planting-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 5,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch-001',
|
||||
plantIds: [plantId],
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: 1,
|
||||
growingEnvironment: 'greenhouse',
|
||||
};
|
||||
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
const qrData = chain.generateQRData(plantId, undefined);
|
||||
|
||||
expect(qrData.plantId).toBe(plantId);
|
||||
expect(qrData.quickLookupUrl).toContain(plantId);
|
||||
expect(qrData.verificationCode).toBeDefined();
|
||||
});
|
||||
|
||||
it('should generate QR data for batch', () => {
|
||||
const batchId = 'qr-batch-001';
|
||||
|
||||
const event = createSeedEvent();
|
||||
event.seedBatchId = batchId;
|
||||
chain.recordEvent(event);
|
||||
|
||||
const qrData = chain.generateQRData(undefined, batchId);
|
||||
|
||||
expect(qrData.batchId).toBe(batchId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return blocks with all required fields', () => {
|
||||
const block = chain.recordEvent(createSeedEvent());
|
||||
|
||||
expect(block.index).toBeDefined();
|
||||
expect(block.timestamp).toBeDefined();
|
||||
expect(block.transportEvent).toBeDefined();
|
||||
expect(block.previousHash).toBeDefined();
|
||||
expect(block.hash).toBeDefined();
|
||||
expect(block.nonce).toBeDefined();
|
||||
expect(block.cumulativeCarbonKg).toBeDefined();
|
||||
expect(block.cumulativeFoodMiles).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function createLocation(type: string): TransportLocation {
|
||||
return {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
locationType: type as any,
|
||||
facilityName: `Test ${type}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createSeedEvent(): SeedAcquisitionEvent {
|
||||
return {
|
||||
id: `seed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 25,
|
||||
durationMinutes: 45,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'seed-bank-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
seedBatchId: `batch-${Date.now()}`,
|
||||
sourceType: 'seed_bank',
|
||||
species: 'Test species',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
};
|
||||
}
|
||||
519
__tests__/api/vertical-farm.test.ts
Normal file
519
__tests__/api/vertical-farm.test.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
/**
|
||||
* Vertical Farm API Tests
|
||||
* Tests for vertical farm-related API endpoints
|
||||
*/
|
||||
|
||||
import {
|
||||
VerticalFarmController,
|
||||
getVerticalFarmController,
|
||||
} from '../../lib/vertical-farming/controller';
|
||||
import {
|
||||
VerticalFarm,
|
||||
GrowingZone,
|
||||
ZoneEnvironmentReadings,
|
||||
} from '../../lib/vertical-farming/types';
|
||||
|
||||
describe('Vertical Farm API', () => {
|
||||
let controller: VerticalFarmController;
|
||||
|
||||
beforeEach(() => {
|
||||
controller = new VerticalFarmController();
|
||||
});
|
||||
|
||||
describe('POST /api/vertical-farm/register', () => {
|
||||
it('should register new farm', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const retrieved = controller.getFarm('api-farm-001');
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.name).toBe('Test Vertical Farm');
|
||||
});
|
||||
|
||||
it('should allow updating farm', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const updatedFarm = createVerticalFarm('api-farm-001');
|
||||
updatedFarm.name = 'Updated Farm Name';
|
||||
controller.registerFarm(updatedFarm);
|
||||
|
||||
const retrieved = controller.getFarm('api-farm-001');
|
||||
expect(retrieved?.name).toBe('Updated Farm Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/vertical-farm/[farmId]', () => {
|
||||
it('should return farm details', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const retrieved = controller.getFarm('api-farm-001');
|
||||
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.id).toBe('api-farm-001');
|
||||
expect(retrieved?.zones.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return undefined for unknown farm', () => {
|
||||
const retrieved = controller.getFarm('unknown-farm');
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/vertical-farm/[farmId]/zones', () => {
|
||||
it('should return farm zones', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const retrieved = controller.getFarm('api-farm-001');
|
||||
const zones = retrieved?.zones;
|
||||
|
||||
expect(zones).toBeDefined();
|
||||
expect(zones?.length).toBeGreaterThan(0);
|
||||
expect(zones?.[0].id).toBe('zone-001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/vertical-farm/[farmId]/zones', () => {
|
||||
it('should add zone to existing farm', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
farm.zones = []; // Start with no zones
|
||||
controller.registerFarm(farm);
|
||||
|
||||
// Add zone by updating farm
|
||||
const updatedFarm = controller.getFarm('api-farm-001')!;
|
||||
updatedFarm.zones.push(createGrowingZone('new-zone', 'New Zone', 1));
|
||||
|
||||
expect(updatedFarm.zones.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/vertical-farm/[farmId]/analytics', () => {
|
||||
it('should return farm analytics', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const analytics = controller.generateAnalytics('api-farm-001', 30);
|
||||
|
||||
expect(analytics.farmId).toBe('api-farm-001');
|
||||
expect(analytics.period).toBe('30 days');
|
||||
});
|
||||
|
||||
it('should include yield metrics', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
// Start and complete a batch for analytics data
|
||||
const recipes = controller.getRecipes();
|
||||
const batch = controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'zone-001',
|
||||
recipes[0].id,
|
||||
'seed-batch-001',
|
||||
100
|
||||
);
|
||||
controller.completeHarvest(batch.id, 15.0, 'A');
|
||||
|
||||
const analytics = controller.generateAnalytics('api-farm-001', 30);
|
||||
|
||||
expect(analytics.totalYieldKg).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should throw error for unknown farm', () => {
|
||||
expect(() => {
|
||||
controller.generateAnalytics('unknown-farm', 30);
|
||||
}).toThrow('Farm unknown-farm not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/vertical-farm/batch/start', () => {
|
||||
it('should start crop batch', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const recipes = controller.getRecipes();
|
||||
const batch = controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'zone-001',
|
||||
recipes[0].id,
|
||||
'seed-batch-001',
|
||||
100
|
||||
);
|
||||
|
||||
expect(batch.id).toBeDefined();
|
||||
expect(batch.plantCount).toBe(100);
|
||||
expect(batch.status).toBe('germinating');
|
||||
});
|
||||
|
||||
it('should validate farm exists', () => {
|
||||
expect(() => {
|
||||
controller.startCropBatch(
|
||||
'unknown-farm',
|
||||
'zone-001',
|
||||
'recipe-001',
|
||||
'seed-001',
|
||||
100
|
||||
);
|
||||
}).toThrow('Farm unknown-farm not found');
|
||||
});
|
||||
|
||||
it('should validate zone exists', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
expect(() => {
|
||||
controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'unknown-zone',
|
||||
'recipe-001',
|
||||
'seed-001',
|
||||
100
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should validate recipe exists', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
expect(() => {
|
||||
controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'zone-001',
|
||||
'unknown-recipe',
|
||||
'seed-001',
|
||||
100
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/vertical-farm/batch/[batchId]', () => {
|
||||
it('should return batch details', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const recipes = controller.getRecipes();
|
||||
const batch = controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'zone-001',
|
||||
recipes[0].id,
|
||||
'seed-batch-001',
|
||||
100
|
||||
);
|
||||
|
||||
// Update progress
|
||||
const updated = controller.updateBatchProgress(batch.id);
|
||||
|
||||
expect(updated.id).toBe(batch.id);
|
||||
expect(updated.currentDay).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should throw error for unknown batch', () => {
|
||||
expect(() => {
|
||||
controller.updateBatchProgress('unknown-batch');
|
||||
}).toThrow('Batch unknown-batch not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/vertical-farm/batch/[batchId]/environment', () => {
|
||||
it('should record environment readings', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const recipes = controller.getRecipes();
|
||||
controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'zone-001',
|
||||
recipes[0].id,
|
||||
'seed-batch-001',
|
||||
100
|
||||
);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = {
|
||||
timestamp: new Date().toISOString(),
|
||||
temperatureC: 22,
|
||||
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: [],
|
||||
};
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
expect(Array.isArray(alerts)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect environment alerts', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const recipes = controller.getRecipes();
|
||||
controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'zone-001',
|
||||
recipes[0].id,
|
||||
'seed-batch-001',
|
||||
100
|
||||
);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = {
|
||||
timestamp: new Date().toISOString(),
|
||||
temperatureC: 35, // Too high
|
||||
humidityPercent: 30, // 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: [],
|
||||
};
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
expect(alerts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/vertical-farm/batch/[batchId]/harvest', () => {
|
||||
it('should complete harvest', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const recipes = controller.getRecipes();
|
||||
const batch = controller.startCropBatch(
|
||||
'api-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');
|
||||
});
|
||||
|
||||
it('should throw error for unknown batch', () => {
|
||||
expect(() => {
|
||||
controller.completeHarvest('unknown-batch', 10, 'A');
|
||||
}).toThrow('Batch unknown-batch not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/vertical-farm/recipes', () => {
|
||||
it('should return all recipes', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
expect(recipes.length).toBeGreaterThan(0);
|
||||
expect(recipes[0].id).toBeDefined();
|
||||
expect(recipes[0].stages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include default recipes', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const cropTypes = recipes.map(r => r.cropType);
|
||||
|
||||
expect(cropTypes).toContain('lettuce');
|
||||
expect(cropTypes).toContain('basil');
|
||||
expect(cropTypes).toContain('microgreens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Formats', () => {
|
||||
it('should return consistent farm format', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const retrieved = controller.getFarm('api-farm-001');
|
||||
|
||||
expect(retrieved?.id).toBeDefined();
|
||||
expect(retrieved?.name).toBeDefined();
|
||||
expect(retrieved?.ownerId).toBeDefined();
|
||||
expect(retrieved?.location).toBeDefined();
|
||||
expect(retrieved?.specs).toBeDefined();
|
||||
expect(retrieved?.zones).toBeDefined();
|
||||
expect(retrieved?.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return consistent batch format', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const recipes = controller.getRecipes();
|
||||
const batch = controller.startCropBatch(
|
||||
'api-farm-001',
|
||||
'zone-001',
|
||||
recipes[0].id,
|
||||
'seed-batch-001',
|
||||
100
|
||||
);
|
||||
|
||||
expect(batch.id).toBeDefined();
|
||||
expect(batch.farmId).toBeDefined();
|
||||
expect(batch.zoneId).toBeDefined();
|
||||
expect(batch.cropType).toBeDefined();
|
||||
expect(batch.plantCount).toBeDefined();
|
||||
expect(batch.status).toBeDefined();
|
||||
expect(batch.expectedYieldKg).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return consistent analytics format', () => {
|
||||
const farm = createVerticalFarm('api-farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const analytics = controller.generateAnalytics('api-farm-001', 30);
|
||||
|
||||
expect(analytics.farmId).toBeDefined();
|
||||
expect(analytics.generatedAt).toBeDefined();
|
||||
expect(analytics.period).toBeDefined();
|
||||
expect(analytics.totalYieldKg).toBeDefined();
|
||||
expect(analytics.cropCyclesCompleted).toBeDefined();
|
||||
expect(analytics.topCropsByYield).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
407
__tests__/integration/demand-to-harvest.test.ts
Normal file
407
__tests__/integration/demand-to-harvest.test.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
/**
|
||||
* Demand to Harvest Integration Tests
|
||||
* Tests the complete flow from demand signal to plant to harvest
|
||||
*/
|
||||
|
||||
import { TransportChain, setTransportChain } from '../../lib/transport/tracker';
|
||||
import { DemandForecaster } from '../../lib/demand/forecaster';
|
||||
import {
|
||||
ConsumerPreference,
|
||||
PlantingRecommendation,
|
||||
} from '../../lib/demand/types';
|
||||
import {
|
||||
SeedAcquisitionEvent,
|
||||
PlantingEvent,
|
||||
HarvestEvent,
|
||||
DistributionEvent,
|
||||
ConsumerDeliveryEvent,
|
||||
TransportLocation,
|
||||
} from '../../lib/transport/types';
|
||||
|
||||
describe('Demand to Harvest Integration', () => {
|
||||
let chain: TransportChain;
|
||||
let forecaster: DemandForecaster;
|
||||
|
||||
beforeEach(() => {
|
||||
chain = new TransportChain(1);
|
||||
setTransportChain(chain);
|
||||
forecaster = new DemandForecaster();
|
||||
});
|
||||
|
||||
describe('Complete Demand-Driven Flow', () => {
|
||||
it('should complete full flow from demand to consumer delivery', () => {
|
||||
// Step 1: Register consumer demand
|
||||
const consumerId = 'consumer-integration-001';
|
||||
const pref: ConsumerPreference = {
|
||||
consumerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
maxDeliveryRadiusKm: 25,
|
||||
city: 'New York',
|
||||
},
|
||||
dietaryType: ['omnivore'],
|
||||
allergies: [],
|
||||
dislikes: [],
|
||||
preferredCategories: ['leafy_greens'],
|
||||
preferredItems: [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 5, seasonalOnly: false },
|
||||
],
|
||||
certificationPreferences: ['organic', 'local'],
|
||||
freshnessImportance: 5,
|
||||
priceImportance: 3,
|
||||
sustainabilityImportance: 5,
|
||||
deliveryPreferences: {
|
||||
method: ['home_delivery'],
|
||||
frequency: 'weekly',
|
||||
preferredDays: ['saturday'],
|
||||
},
|
||||
householdSize: 4,
|
||||
weeklyBudget: 100,
|
||||
currency: 'USD',
|
||||
};
|
||||
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Step 2: Generate demand signal
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7128, -74.006, 50, 'NYC Metro', 'spring'
|
||||
);
|
||||
|
||||
expect(signal.totalConsumers).toBe(1);
|
||||
expect(signal.demandItems.length).toBeGreaterThan(0);
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem).toBeDefined();
|
||||
expect(lettuceItem!.weeklyDemandKg).toBe(20); // 5 kg * 4 household size
|
||||
|
||||
// Step 3: Generate planting recommendation for grower
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-integration-001',
|
||||
40.72, -74.01, // Near NYC
|
||||
50, // 50km delivery radius
|
||||
100, // 100 sqm available
|
||||
'spring'
|
||||
);
|
||||
|
||||
expect(recommendations.length).toBeGreaterThan(0);
|
||||
const lettuceRec = recommendations.find(r => r.produceType === 'lettuce');
|
||||
expect(lettuceRec).toBeDefined();
|
||||
|
||||
// Step 4: Grower follows recommendation and plants
|
||||
const seedBatchId = 'demand-driven-batch-001';
|
||||
const plantIds = ['demand-plant-001', 'demand-plant-002'];
|
||||
|
||||
const seedEvent: SeedAcquisitionEvent = {
|
||||
id: 'demand-seed-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank', 'Local Organic Seeds'),
|
||||
toLocation: createLocation('greenhouse', 'Grower Greenhouse'),
|
||||
distanceKm: 10,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'seed-supplier',
|
||||
receiverId: 'grower-integration-001',
|
||||
status: 'verified',
|
||||
seedBatchId,
|
||||
sourceType: 'purchase',
|
||||
species: 'Lactuca sativa',
|
||||
variety: 'Butterhead',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
certifications: ['organic'],
|
||||
};
|
||||
|
||||
chain.recordEvent(seedEvent);
|
||||
|
||||
const plantingEvent: PlantingEvent = {
|
||||
id: 'demand-planting-001',
|
||||
timestamp: new Date(Date.now() + 1000).toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse', 'Grower Greenhouse'),
|
||||
toLocation: createLocation('greenhouse', 'Grower Greenhouse'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-integration-001',
|
||||
receiverId: 'grower-integration-001',
|
||||
status: 'verified',
|
||||
seedBatchId,
|
||||
plantIds,
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: plantIds.length,
|
||||
growingEnvironment: 'greenhouse',
|
||||
expectedHarvestDate: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
// Step 5: Harvest
|
||||
const harvestBatchId = 'demand-harvest-batch-001';
|
||||
|
||||
const harvestEvent: HarvestEvent = {
|
||||
id: 'demand-harvest-001',
|
||||
timestamp: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
eventType: 'harvest',
|
||||
fromLocation: createLocation('greenhouse', 'Grower Greenhouse'),
|
||||
toLocation: createLocation('hub', 'Distribution Hub'),
|
||||
distanceKm: 5,
|
||||
durationMinutes: 15,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-integration-001',
|
||||
receiverId: 'hub-distribution',
|
||||
status: 'verified',
|
||||
plantIds,
|
||||
harvestBatchId,
|
||||
harvestType: 'full',
|
||||
produceType: 'Butterhead Lettuce',
|
||||
grossWeight: 2,
|
||||
netWeight: 1.8,
|
||||
weightUnit: 'kg',
|
||||
qualityGrade: 'A',
|
||||
packagingType: 'sustainable_boxes',
|
||||
temperatureRequired: { min: 2, max: 8, optimal: 4, unit: 'celsius' },
|
||||
shelfLifeHours: 168,
|
||||
seedsSaved: false,
|
||||
};
|
||||
|
||||
chain.recordEvent(harvestEvent);
|
||||
|
||||
// Step 6: Distribution
|
||||
const distributionEvent: DistributionEvent = {
|
||||
id: 'demand-distribution-001',
|
||||
timestamp: new Date(Date.now() + 46 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
eventType: 'distribution',
|
||||
fromLocation: createLocation('hub', 'Distribution Hub'),
|
||||
toLocation: createLocation('market', 'Local Delivery Zone'),
|
||||
distanceKm: 10,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'electric_truck',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'hub-distribution',
|
||||
receiverId: 'delivery-service',
|
||||
status: 'verified',
|
||||
batchIds: [harvestBatchId],
|
||||
destinationType: 'consumer',
|
||||
customerType: 'individual',
|
||||
deliveryWindow: {
|
||||
start: new Date(Date.now() + 46 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(Date.now() + 46.5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
deliveryAttempts: 1,
|
||||
handoffVerified: true,
|
||||
};
|
||||
|
||||
chain.recordEvent(distributionEvent);
|
||||
|
||||
// Step 7: Consumer Delivery
|
||||
const deliveryEvent: ConsumerDeliveryEvent = {
|
||||
id: 'demand-delivery-001',
|
||||
timestamp: new Date(Date.now() + 46.25 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
eventType: 'consumer_delivery',
|
||||
fromLocation: createLocation('market', 'Local Delivery Zone'),
|
||||
toLocation: createLocation('consumer', 'Consumer Home'),
|
||||
distanceKm: 3,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'delivery-service',
|
||||
receiverId: consumerId,
|
||||
status: 'verified',
|
||||
orderId: 'order-integration-001',
|
||||
batchIds: [harvestBatchId],
|
||||
deliveryMethod: 'home_delivery',
|
||||
finalMileMethod: 'electric_vehicle',
|
||||
packagingReturned: true,
|
||||
feedbackReceived: true,
|
||||
feedbackRating: 5,
|
||||
feedbackNotes: 'Fresh and delicious!',
|
||||
};
|
||||
|
||||
chain.recordEvent(deliveryEvent);
|
||||
|
||||
// Verify complete chain
|
||||
expect(chain.isChainValid()).toBe(true);
|
||||
expect(chain.chain.length).toBe(7); // Genesis + 6 events
|
||||
|
||||
// Verify plant journey
|
||||
const journey = chain.getPlantJourney(plantIds[0]);
|
||||
expect(journey).not.toBeNull();
|
||||
expect(journey!.events.length).toBe(3); // planting, harvest, and possibly more
|
||||
|
||||
// Verify environmental impact
|
||||
const impact = chain.getEnvironmentalImpact('grower-integration-001');
|
||||
expect(impact.totalFoodMiles).toBeLessThan(50); // Local delivery
|
||||
expect(impact.comparisonToConventional.percentageReduction).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supply Registration Affects Recommendations', () => {
|
||||
it('should adjust recommendations based on existing supply', () => {
|
||||
// Register high demand
|
||||
const pref = createConsumerPreference('consumer-001');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 100, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
// Get recommendations without supply
|
||||
const recsWithoutSupply = forecaster.generatePlantingRecommendations(
|
||||
'grower-001', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
// Register significant supply
|
||||
forecaster.registerSupply({
|
||||
id: 'supply-001',
|
||||
growerId: 'other-grower',
|
||||
timestamp: new Date().toISOString(),
|
||||
produceType: 'lettuce',
|
||||
committedQuantityKg: 80,
|
||||
availableFrom: new Date().toISOString(),
|
||||
availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
pricePerKg: 5,
|
||||
currency: 'USD',
|
||||
minimumOrderKg: 1,
|
||||
certifications: ['organic'],
|
||||
freshnessGuaranteeHours: 48,
|
||||
deliveryRadiusKm: 50,
|
||||
deliveryMethods: ['grower_delivery'],
|
||||
status: 'available',
|
||||
remainingKg: 80,
|
||||
});
|
||||
|
||||
// Generate new demand signal (with supply factored in)
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
// Get recommendations with supply
|
||||
const recsWithSupply = forecaster.generatePlantingRecommendations(
|
||||
'grower-001', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
// Recommendations should be different (less space recommended for lettuce)
|
||||
if (recsWithoutSupply.length > 0 && recsWithSupply.length > 0) {
|
||||
const lettuceWithout = recsWithoutSupply.find(r => r.produceType === 'lettuce');
|
||||
const lettuceWith = recsWithSupply.find(r => r.produceType === 'lettuce');
|
||||
|
||||
if (lettuceWithout && lettuceWith) {
|
||||
expect(lettuceWith.recommendedQuantity).toBeLessThanOrEqual(lettuceWithout.recommendedQuantity);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regional Demand Matching', () => {
|
||||
it('should match growers with regional demand', () => {
|
||||
// Create consumers in different regions
|
||||
const nycConsumer = createConsumerPreference('nyc-consumer');
|
||||
nycConsumer.location = { latitude: 40.7128, longitude: -74.006, maxDeliveryRadiusKm: 10 };
|
||||
forecaster.registerPreference(nycConsumer);
|
||||
|
||||
const laConsumer = createConsumerPreference('la-consumer');
|
||||
laConsumer.location = { latitude: 34.0522, longitude: -118.2437, maxDeliveryRadiusKm: 10 };
|
||||
forecaster.registerPreference(laConsumer);
|
||||
|
||||
// Generate signals for each region
|
||||
const nycSignal = forecaster.generateDemandSignal(40.7128, -74.006, 20, 'NYC', 'spring');
|
||||
const laSignal = forecaster.generateDemandSignal(34.0522, -118.2437, 20, 'LA', 'spring');
|
||||
|
||||
expect(nycSignal.totalConsumers).toBe(1);
|
||||
expect(laSignal.totalConsumers).toBe(1);
|
||||
|
||||
// NYC grower should only see NYC demand
|
||||
const nycGrowerRecs = forecaster.generatePlantingRecommendations(
|
||||
'nyc-grower', 40.72, -74.01, 25, 100, 'spring'
|
||||
);
|
||||
|
||||
// LA grower should only see LA demand
|
||||
const laGrowerRecs = forecaster.generatePlantingRecommendations(
|
||||
'la-grower', 34.06, -118.25, 25, 100, 'spring'
|
||||
);
|
||||
|
||||
// Both should have recommendations from their respective regions
|
||||
expect(nycGrowerRecs.length + laGrowerRecs.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Seasonal Demand Flow', () => {
|
||||
it('should respect seasonal availability in recommendations', () => {
|
||||
const pref = createConsumerPreference('seasonal-consumer');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'must_have', weeklyQuantity: 5, seasonalOnly: false },
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 5, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Winter recommendations
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'winter');
|
||||
const winterRecs = forecaster.generatePlantingRecommendations(
|
||||
'grower-001', 40.7, -74.0, 50, 100, 'winter'
|
||||
);
|
||||
|
||||
// Tomato should not be recommended in winter (not in season)
|
||||
const tomatoWinter = winterRecs.find(r => r.produceType === 'tomato');
|
||||
expect(tomatoWinter).toBeUndefined();
|
||||
|
||||
// Summer recommendations
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'summer');
|
||||
const summerRecs = forecaster.generatePlantingRecommendations(
|
||||
'grower-001', 40.7, -74.0, 50, 100, 'summer'
|
||||
);
|
||||
|
||||
// Tomato should be recommended in summer
|
||||
const tomatoSummer = summerRecs.find(r => r.produceType === 'tomato');
|
||||
expect(tomatoSummer).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
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 createConsumerPreference(consumerId: string): ConsumerPreference {
|
||||
return {
|
||||
consumerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
maxDeliveryRadiusKm: 25,
|
||||
},
|
||||
dietaryType: ['omnivore'],
|
||||
allergies: [],
|
||||
dislikes: [],
|
||||
preferredCategories: ['leafy_greens'],
|
||||
preferredItems: [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 2, seasonalOnly: false },
|
||||
],
|
||||
certificationPreferences: ['organic'],
|
||||
freshnessImportance: 4,
|
||||
priceImportance: 3,
|
||||
sustainabilityImportance: 4,
|
||||
deliveryPreferences: {
|
||||
method: ['home_delivery'],
|
||||
frequency: 'weekly',
|
||||
preferredDays: ['saturday'],
|
||||
},
|
||||
householdSize: 2,
|
||||
weeklyBudget: 100,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
532
__tests__/integration/seed-to-seed.test.ts
Normal file
532
__tests__/integration/seed-to-seed.test.ts
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
/**
|
||||
* Seed-to-Seed Lifecycle Integration Tests
|
||||
* Tests complete lifecycle from seed acquisition to seed saving
|
||||
*/
|
||||
|
||||
import { TransportChain, setTransportChain } from '../../lib/transport/tracker';
|
||||
import {
|
||||
SeedAcquisitionEvent,
|
||||
PlantingEvent,
|
||||
GrowingTransportEvent,
|
||||
HarvestEvent,
|
||||
SeedSavingEvent,
|
||||
SeedSharingEvent,
|
||||
TransportLocation,
|
||||
} from '../../lib/transport/types';
|
||||
|
||||
describe('Seed-to-Seed Lifecycle', () => {
|
||||
let chain: TransportChain;
|
||||
|
||||
beforeEach(() => {
|
||||
chain = new TransportChain(1); // Low difficulty for faster tests
|
||||
setTransportChain(chain);
|
||||
});
|
||||
|
||||
describe('Complete Lifecycle', () => {
|
||||
it('should track complete lifecycle from seed acquisition to seed saving', () => {
|
||||
const seedBatchId = 'lifecycle-batch-001';
|
||||
const plantIds = ['plant-lifecycle-001', 'plant-lifecycle-002'];
|
||||
const newSeedBatchId = 'lifecycle-batch-002';
|
||||
|
||||
// Step 1: Seed Acquisition
|
||||
const seedAcquisition: SeedAcquisitionEvent = {
|
||||
id: 'lifecycle-seed-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank', 'Heritage Seed Bank'),
|
||||
toLocation: createLocation('greenhouse', 'Local Grower Greenhouse'),
|
||||
distanceKm: 50,
|
||||
durationMinutes: 60,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'seed-bank-heritage',
|
||||
receiverId: 'grower-local-001',
|
||||
status: 'verified',
|
||||
seedBatchId,
|
||||
sourceType: 'seed_bank',
|
||||
species: 'Solanum lycopersicum',
|
||||
variety: 'Brandywine',
|
||||
quantity: 50,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 3,
|
||||
germinationRate: 92,
|
||||
certifications: ['heirloom', 'organic'],
|
||||
};
|
||||
|
||||
const block1 = chain.recordEvent(seedAcquisition);
|
||||
expect(block1.transportEvent.eventType).toBe('seed_acquisition');
|
||||
|
||||
// Step 2: Planting
|
||||
const planting: PlantingEvent = {
|
||||
id: 'lifecycle-planting-001',
|
||||
timestamp: new Date(Date.now() + 1000).toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse', 'Local Grower Greenhouse'),
|
||||
toLocation: createLocation('greenhouse', 'Local Grower Greenhouse'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-local-001',
|
||||
receiverId: 'grower-local-001',
|
||||
status: 'verified',
|
||||
seedBatchId,
|
||||
plantIds,
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: 2,
|
||||
growingEnvironment: 'greenhouse',
|
||||
expectedHarvestDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
const block2 = chain.recordEvent(planting);
|
||||
expect(block2.transportEvent.eventType).toBe('planting');
|
||||
|
||||
// Step 3: Growing Transport (transplant to outdoor garden)
|
||||
const transplant: GrowingTransportEvent = {
|
||||
id: 'lifecycle-transplant-001',
|
||||
timestamp: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days later
|
||||
eventType: 'growing_transport',
|
||||
fromLocation: createLocation('greenhouse', 'Local Grower Greenhouse'),
|
||||
toLocation: createLocation('farm', 'Local Community Garden'),
|
||||
distanceKm: 2,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'bicycle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-local-001',
|
||||
receiverId: 'grower-local-001',
|
||||
status: 'verified',
|
||||
plantIds,
|
||||
reason: 'transplant',
|
||||
plantStage: 'vegetative',
|
||||
handlingMethod: 'potted',
|
||||
rootDisturbance: 'minimal',
|
||||
acclimatizationRequired: true,
|
||||
acclimatizationDays: 7,
|
||||
};
|
||||
|
||||
const block3 = chain.recordEvent(transplant);
|
||||
expect(block3.transportEvent.eventType).toBe('growing_transport');
|
||||
|
||||
// Step 4: Harvest
|
||||
const harvest: HarvestEvent = {
|
||||
id: 'lifecycle-harvest-001',
|
||||
timestamp: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), // 90 days after planting
|
||||
eventType: 'harvest',
|
||||
fromLocation: createLocation('farm', 'Local Community Garden'),
|
||||
toLocation: createLocation('warehouse', 'Local Co-op Distribution'),
|
||||
distanceKm: 5,
|
||||
durationMinutes: 15,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-local-001',
|
||||
receiverId: 'coop-distribution',
|
||||
status: 'verified',
|
||||
plantIds,
|
||||
harvestBatchId: 'harvest-lifecycle-001',
|
||||
harvestType: 'full',
|
||||
produceType: 'Brandywine Tomatoes',
|
||||
grossWeight: 8,
|
||||
netWeight: 7.5,
|
||||
weightUnit: 'kg',
|
||||
qualityGrade: 'A',
|
||||
packagingType: 'sustainable_crates',
|
||||
temperatureRequired: { min: 12, max: 18, optimal: 15, unit: 'celsius' },
|
||||
shelfLifeHours: 168,
|
||||
seedsSaved: true,
|
||||
seedBatchIdCreated: newSeedBatchId,
|
||||
};
|
||||
|
||||
const block4 = chain.recordEvent(harvest);
|
||||
expect(block4.transportEvent.eventType).toBe('harvest');
|
||||
|
||||
// Step 5: Seed Saving
|
||||
const seedSaving: SeedSavingEvent = {
|
||||
id: 'lifecycle-seed-saving-001',
|
||||
timestamp: new Date(Date.now() + 95 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
eventType: 'seed_saving',
|
||||
fromLocation: createLocation('farm', 'Local Community Garden'),
|
||||
toLocation: createLocation('seed_bank', 'Grower Home Seed Storage'),
|
||||
distanceKm: 1,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-local-001',
|
||||
receiverId: 'grower-local-001',
|
||||
status: 'verified',
|
||||
parentPlantIds: plantIds,
|
||||
newSeedBatchId,
|
||||
collectionMethod: 'fermentation',
|
||||
seedCount: 200,
|
||||
germinationRate: 88,
|
||||
storageConditions: {
|
||||
temperature: 10,
|
||||
humidity: 30,
|
||||
lightExposure: 'dark',
|
||||
containerType: 'jar',
|
||||
desiccant: true,
|
||||
estimatedViability: 5,
|
||||
},
|
||||
storageLocationId: 'grower-home-storage',
|
||||
newGenerationNumber: 4,
|
||||
geneticNotes: 'Selected from best performing plants',
|
||||
availableForSharing: true,
|
||||
sharingTerms: 'trade',
|
||||
};
|
||||
|
||||
const block5 = chain.recordEvent(seedSaving);
|
||||
expect(block5.transportEvent.eventType).toBe('seed_saving');
|
||||
|
||||
// Verify complete journey
|
||||
const journey1 = chain.getPlantJourney(plantIds[0]);
|
||||
expect(journey1).not.toBeNull();
|
||||
expect(journey1?.events.length).toBe(4); // planting, transplant, harvest, seed_saving
|
||||
expect(journey1?.currentStage).toBe('seed_saving');
|
||||
expect(journey1?.descendantSeedBatches).toContain(newSeedBatchId);
|
||||
|
||||
// Verify chain integrity
|
||||
expect(chain.isChainValid()).toBe(true);
|
||||
|
||||
// Verify carbon tracking
|
||||
expect(block5.cumulativeCarbonKg).toBeGreaterThanOrEqual(0);
|
||||
expect(block5.cumulativeFoodMiles).toBe(
|
||||
seedAcquisition.distanceKm +
|
||||
planting.distanceKm +
|
||||
transplant.distanceKm +
|
||||
harvest.distanceKm +
|
||||
seedSaving.distanceKm
|
||||
);
|
||||
});
|
||||
|
||||
it('should track multiple generations', () => {
|
||||
// Generation 1 seeds
|
||||
const gen1Batch = 'gen1-batch';
|
||||
const gen1Plants = ['gen1-plant-001'];
|
||||
const gen2Batch = 'gen2-batch';
|
||||
const gen2Plants = ['gen2-plant-001'];
|
||||
const gen3Batch = 'gen3-batch';
|
||||
|
||||
// Gen 1: Acquire, Plant, Harvest with seed saving
|
||||
chain.recordEvent(createSeedAcquisition(gen1Batch, 1));
|
||||
chain.recordEvent(createPlanting(gen1Batch, gen1Plants));
|
||||
chain.recordEvent(createHarvest(gen1Plants, gen2Batch));
|
||||
chain.recordEvent(createSeedSaving(gen1Plants, gen2Batch, 2));
|
||||
|
||||
// Gen 2: Plant saved seeds, Harvest with seed saving
|
||||
chain.recordEvent(createSeedAcquisition(gen2Batch, 2, gen1Plants));
|
||||
chain.recordEvent(createPlanting(gen2Batch, gen2Plants));
|
||||
chain.recordEvent(createHarvest(gen2Plants, gen3Batch));
|
||||
chain.recordEvent(createSeedSaving(gen2Plants, gen3Batch, 3));
|
||||
|
||||
// Verify chain
|
||||
expect(chain.isChainValid()).toBe(true);
|
||||
expect(chain.chain.length).toBe(9); // 1 genesis + 8 events
|
||||
|
||||
// Verify lineage
|
||||
const journey = chain.getPlantJourney('gen2-plant-001');
|
||||
expect(journey?.generation).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Seed Sharing', () => {
|
||||
it('should track seed sharing between growers', () => {
|
||||
const originalBatch = 'original-batch';
|
||||
const sharedQuantity = 25;
|
||||
|
||||
// Save seeds
|
||||
const seedSaving: SeedSavingEvent = {
|
||||
id: 'share-seed-saving',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_saving',
|
||||
fromLocation: createLocation('farm', 'Grower A Farm'),
|
||||
toLocation: createLocation('seed_bank', 'Grower A Storage'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-a',
|
||||
receiverId: 'grower-a',
|
||||
status: 'verified',
|
||||
parentPlantIds: ['parent-001'],
|
||||
newSeedBatchId: originalBatch,
|
||||
collectionMethod: 'dry_seed',
|
||||
seedCount: 100,
|
||||
storageConditions: {
|
||||
temperature: 10,
|
||||
humidity: 30,
|
||||
lightExposure: 'dark',
|
||||
containerType: 'envelope',
|
||||
desiccant: true,
|
||||
estimatedViability: 4,
|
||||
},
|
||||
storageLocationId: 'grower-a-storage',
|
||||
newGenerationNumber: 2,
|
||||
availableForSharing: true,
|
||||
sharingTerms: 'trade',
|
||||
};
|
||||
|
||||
chain.recordEvent(seedSaving);
|
||||
|
||||
// Share seeds
|
||||
const seedSharing: SeedSharingEvent = {
|
||||
id: 'share-event-001',
|
||||
timestamp: new Date(Date.now() + 1000).toISOString(),
|
||||
eventType: 'seed_sharing',
|
||||
fromLocation: createLocation('seed_bank', 'Grower A Storage'),
|
||||
toLocation: createLocation('greenhouse', 'Grower B Greenhouse'),
|
||||
distanceKm: 10,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'bicycle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-a',
|
||||
receiverId: 'grower-b',
|
||||
status: 'verified',
|
||||
seedBatchId: originalBatch,
|
||||
quantityShared: sharedQuantity,
|
||||
quantityUnit: 'seeds',
|
||||
sharingType: 'trade',
|
||||
tradeDetails: 'Traded for basil seeds',
|
||||
recipientAgreement: true,
|
||||
growingCommitment: 'Will grow and save seeds',
|
||||
reportBackRequired: true,
|
||||
};
|
||||
|
||||
const block = chain.recordEvent(seedSharing);
|
||||
|
||||
expect(block.transportEvent.eventType).toBe('seed_sharing');
|
||||
expect((block.transportEvent as SeedSharingEvent).quantityShared).toBe(sharedQuantity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environmental Impact Across Lifecycle', () => {
|
||||
it('should calculate cumulative environmental impact', () => {
|
||||
const userId = 'eco-grower';
|
||||
|
||||
// Record full lifecycle with the same user
|
||||
const events = [
|
||||
{ distanceKm: 50, method: 'electric_vehicle' as const },
|
||||
{ distanceKm: 0, method: 'walking' as const },
|
||||
{ distanceKm: 5, method: 'bicycle' as const },
|
||||
{ distanceKm: 10, method: 'electric_truck' as const },
|
||||
{ distanceKm: 1, method: 'walking' as const },
|
||||
];
|
||||
|
||||
let totalDistance = 0;
|
||||
events.forEach((event, i) => {
|
||||
chain.recordEvent({
|
||||
id: `eco-event-${i}`,
|
||||
timestamp: new Date(Date.now() + i * 1000).toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('farm', 'Location A'),
|
||||
toLocation: createLocation('farm', 'Location B'),
|
||||
distanceKm: event.distanceKm,
|
||||
durationMinutes: 30,
|
||||
transportMethod: event.method,
|
||||
carbonFootprintKg: 0,
|
||||
senderId: userId,
|
||||
receiverId: userId,
|
||||
status: 'verified',
|
||||
seedBatchId: `batch-${i}`,
|
||||
sourceType: 'previous_harvest',
|
||||
species: 'Test',
|
||||
quantity: 10,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
} as SeedAcquisitionEvent);
|
||||
totalDistance += event.distanceKm;
|
||||
});
|
||||
|
||||
const impact = chain.getEnvironmentalImpact(userId);
|
||||
|
||||
expect(impact.totalFoodMiles).toBe(totalDistance);
|
||||
expect(impact.comparisonToConventional.milesSaved).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR Code Traceability', () => {
|
||||
it('should generate traceable QR codes at each stage', () => {
|
||||
const plantId = 'qr-trace-plant';
|
||||
const batchId = 'qr-trace-batch';
|
||||
|
||||
// Record seed acquisition
|
||||
chain.recordEvent({
|
||||
id: 'qr-seed',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank', 'Bank'),
|
||||
toLocation: createLocation('greenhouse', 'Greenhouse'),
|
||||
distanceKm: 10,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'bicycle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'seller',
|
||||
receiverId: 'buyer',
|
||||
status: 'verified',
|
||||
seedBatchId: batchId,
|
||||
sourceType: 'seed_bank',
|
||||
species: 'Test',
|
||||
quantity: 10,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
} as SeedAcquisitionEvent);
|
||||
|
||||
// Record planting
|
||||
chain.recordEvent({
|
||||
id: 'qr-planting',
|
||||
timestamp: new Date(Date.now() + 1000).toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse', 'Greenhouse'),
|
||||
toLocation: createLocation('greenhouse', 'Greenhouse'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'buyer',
|
||||
receiverId: 'buyer',
|
||||
status: 'verified',
|
||||
seedBatchId: batchId,
|
||||
plantIds: [plantId],
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: 1,
|
||||
growingEnvironment: 'greenhouse',
|
||||
} as PlantingEvent);
|
||||
|
||||
// Generate QR for plant
|
||||
const plantQR = chain.generateQRData(plantId, undefined);
|
||||
expect(plantQR.plantId).toBe(plantId);
|
||||
expect(plantQR.lastEventType).toBe('planting');
|
||||
|
||||
// Generate QR for batch
|
||||
const batchQR = chain.generateQRData(undefined, batchId);
|
||||
expect(batchQR.batchId).toBe(batchId);
|
||||
|
||||
// Both should have valid verification codes
|
||||
expect(plantQR.verificationCode).toMatch(/^[A-F0-9]{8}$/);
|
||||
expect(batchQR.verificationCode).toMatch(/^[A-F0-9]{8}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
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 createSeedAcquisition(
|
||||
batchId: string,
|
||||
generation: number,
|
||||
parentPlantIds?: string[]
|
||||
): SeedAcquisitionEvent {
|
||||
return {
|
||||
id: `seed-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank', 'Bank'),
|
||||
toLocation: createLocation('greenhouse', 'Greenhouse'),
|
||||
distanceKm: 10,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'sender',
|
||||
receiverId: 'receiver',
|
||||
status: 'verified',
|
||||
seedBatchId: batchId,
|
||||
sourceType: parentPlantIds ? 'previous_harvest' : 'seed_bank',
|
||||
species: 'Test Species',
|
||||
quantity: 10,
|
||||
quantityUnit: 'seeds',
|
||||
generation,
|
||||
parentPlantIds,
|
||||
};
|
||||
}
|
||||
|
||||
function createPlanting(batchId: string, plantIds: string[]): PlantingEvent {
|
||||
return {
|
||||
id: `planting-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
||||
timestamp: new Date(Date.now() + 100).toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse', 'Greenhouse'),
|
||||
toLocation: createLocation('greenhouse', 'Greenhouse'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower',
|
||||
receiverId: 'grower',
|
||||
status: 'verified',
|
||||
seedBatchId: batchId,
|
||||
plantIds,
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: plantIds.length,
|
||||
growingEnvironment: 'greenhouse',
|
||||
};
|
||||
}
|
||||
|
||||
function createHarvest(plantIds: string[], newSeedBatchId: string): HarvestEvent {
|
||||
return {
|
||||
id: `harvest-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
||||
timestamp: new Date(Date.now() + 200).toISOString(),
|
||||
eventType: 'harvest',
|
||||
fromLocation: createLocation('farm', 'Farm'),
|
||||
toLocation: createLocation('warehouse', 'Warehouse'),
|
||||
distanceKm: 5,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'electric_truck',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower',
|
||||
receiverId: 'distributor',
|
||||
status: 'verified',
|
||||
plantIds,
|
||||
harvestBatchId: `harvest-batch-${Date.now()}`,
|
||||
harvestType: 'full',
|
||||
produceType: 'Test Produce',
|
||||
grossWeight: 5,
|
||||
netWeight: 4.5,
|
||||
weightUnit: 'kg',
|
||||
packagingType: 'crates',
|
||||
temperatureRequired: { min: 10, max: 20, optimal: 15, unit: 'celsius' },
|
||||
shelfLifeHours: 168,
|
||||
seedsSaved: true,
|
||||
seedBatchIdCreated: newSeedBatchId,
|
||||
};
|
||||
}
|
||||
|
||||
function createSeedSaving(
|
||||
parentPlantIds: string[],
|
||||
newSeedBatchId: string,
|
||||
generation: number
|
||||
): SeedSavingEvent {
|
||||
return {
|
||||
id: `save-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
||||
timestamp: new Date(Date.now() + 300).toISOString(),
|
||||
eventType: 'seed_saving',
|
||||
fromLocation: createLocation('farm', 'Farm'),
|
||||
toLocation: createLocation('seed_bank', 'Storage'),
|
||||
distanceKm: 0,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower',
|
||||
receiverId: 'grower',
|
||||
status: 'verified',
|
||||
parentPlantIds,
|
||||
newSeedBatchId,
|
||||
collectionMethod: 'dry_seed',
|
||||
seedCount: 50,
|
||||
storageConditions: {
|
||||
temperature: 10,
|
||||
humidity: 30,
|
||||
lightExposure: 'dark',
|
||||
containerType: 'envelope',
|
||||
desiccant: true,
|
||||
estimatedViability: 4,
|
||||
},
|
||||
storageLocationId: 'grower-storage',
|
||||
newGenerationNumber: generation,
|
||||
availableForSharing: true,
|
||||
};
|
||||
}
|
||||
558
__tests__/integration/vf-batch-lifecycle.test.ts
Normal file
558
__tests__/integration/vf-batch-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
/**
|
||||
* 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: [],
|
||||
};
|
||||
}
|
||||
374
__tests__/lib/demand/aggregation.test.ts
Normal file
374
__tests__/lib/demand/aggregation.test.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* Demand Aggregation Tests
|
||||
* Tests for demand aggregation logic
|
||||
*/
|
||||
|
||||
import {
|
||||
DemandForecaster,
|
||||
} from '../../../lib/demand/forecaster';
|
||||
import {
|
||||
ConsumerPreference,
|
||||
DemandSignal,
|
||||
DemandItem,
|
||||
} from '../../../lib/demand/types';
|
||||
|
||||
describe('Demand Aggregation', () => {
|
||||
let forecaster: DemandForecaster;
|
||||
|
||||
beforeEach(() => {
|
||||
forecaster = new DemandForecaster();
|
||||
});
|
||||
|
||||
describe('Consumer Aggregation', () => {
|
||||
it('should aggregate demand from multiple consumers', () => {
|
||||
// Add 5 consumers all wanting lettuce
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const pref = createConsumerPreference(`consumer-${i}`);
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 2;
|
||||
forecaster.registerPreference(pref);
|
||||
}
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test Region', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem?.consumerCount).toBe(5);
|
||||
// Total demand = 5 consumers * 1kg/week * 2 household = 10 kg
|
||||
expect(lettuceItem?.weeklyDemandKg).toBe(10);
|
||||
});
|
||||
|
||||
it('should aggregate different produce types separately', () => {
|
||||
const pref1 = createConsumerPreference('consumer-1');
|
||||
pref1.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref1);
|
||||
|
||||
const pref2 = createConsumerPreference('consumer-2');
|
||||
pref2.preferredItems = [
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'must_have', weeklyQuantity: 2, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref2);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
const tomatoItem = signal.demandItems.find(i => i.produceType === 'tomato');
|
||||
|
||||
expect(lettuceItem).toBeDefined();
|
||||
expect(tomatoItem).toBeDefined();
|
||||
expect(lettuceItem?.consumerCount).toBe(1);
|
||||
expect(tomatoItem?.consumerCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should calculate monthly demand from weekly', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 5, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem?.monthlyDemandKg).toBe(lettuceItem!.weeklyDemandKg * 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Priority Aggregation', () => {
|
||||
it('should calculate aggregate priority from consumer priorities', () => {
|
||||
const pref1 = createConsumerPreference('consumer-1');
|
||||
pref1.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref1);
|
||||
|
||||
const pref2 = createConsumerPreference('consumer-2');
|
||||
pref2.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'occasional', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref2);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
// must_have=10, occasional=2, average = 6
|
||||
expect(lettuceItem?.aggregatePriority).toBe(6);
|
||||
});
|
||||
|
||||
it('should determine urgency from aggregate priority', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem?.urgency).toBe('immediate');
|
||||
});
|
||||
|
||||
it('should sort demand items by priority', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'occasional', weeklyQuantity: 1, seasonalOnly: false },
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
// Tomato (must_have) should come before lettuce (occasional)
|
||||
expect(signal.demandItems[0].produceType).toBe('tomato');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certification Aggregation', () => {
|
||||
it('should aggregate certification preferences', () => {
|
||||
const pref1 = createConsumerPreference('consumer-1');
|
||||
pref1.certificationPreferences = ['organic'];
|
||||
forecaster.registerPreference(pref1);
|
||||
|
||||
const pref2 = createConsumerPreference('consumer-2');
|
||||
pref2.certificationPreferences = ['organic', 'local'];
|
||||
forecaster.registerPreference(pref2);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
// Check that certification preferences are aggregated
|
||||
signal.demandItems.forEach(item => {
|
||||
expect(item.preferredCertifications).toContain('organic');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Price Aggregation', () => {
|
||||
it('should calculate average willing price', () => {
|
||||
const pref1 = createConsumerPreference('consumer-1');
|
||||
pref1.weeklyBudget = 100;
|
||||
pref1.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 2, seasonalOnly: false },
|
||||
];
|
||||
pref1.householdSize = 1;
|
||||
forecaster.registerPreference(pref1);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem?.averageWillingPrice).toBeGreaterThan(0);
|
||||
expect(lettuceItem?.priceUnit).toBe('per_kg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supply Gap Calculation', () => {
|
||||
it('should calculate supply gap when no supply', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem?.gapKg).toBe(10);
|
||||
expect(lettuceItem?.matchedSupply).toBe(0);
|
||||
});
|
||||
|
||||
it('should reduce gap when supply is available', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Add partial supply
|
||||
forecaster.registerSupply({
|
||||
id: 'supply-1',
|
||||
growerId: 'grower-1',
|
||||
timestamp: new Date().toISOString(),
|
||||
produceType: 'lettuce',
|
||||
committedQuantityKg: 5,
|
||||
availableFrom: new Date().toISOString(),
|
||||
availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
pricePerKg: 5,
|
||||
currency: 'USD',
|
||||
minimumOrderKg: 1,
|
||||
certifications: [],
|
||||
freshnessGuaranteeHours: 48,
|
||||
deliveryRadiusKm: 50,
|
||||
deliveryMethods: ['grower_delivery'],
|
||||
status: 'available',
|
||||
remainingKg: 5,
|
||||
});
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem?.matchedSupply).toBe(5);
|
||||
expect(lettuceItem?.gapKg).toBe(5);
|
||||
});
|
||||
|
||||
it('should show surplus when supply exceeds demand', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 5, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Add excess supply
|
||||
forecaster.registerSupply({
|
||||
id: 'supply-1',
|
||||
growerId: 'grower-1',
|
||||
timestamp: new Date().toISOString(),
|
||||
produceType: 'lettuce',
|
||||
committedQuantityKg: 20,
|
||||
availableFrom: new Date().toISOString(),
|
||||
availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
pricePerKg: 5,
|
||||
currency: 'USD',
|
||||
minimumOrderKg: 1,
|
||||
certifications: [],
|
||||
freshnessGuaranteeHours: 48,
|
||||
deliveryRadiusKm: 50,
|
||||
deliveryMethods: ['grower_delivery'],
|
||||
status: 'available',
|
||||
remainingKg: 20,
|
||||
});
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.supplyStatus).toBe('surplus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Seasonal Availability', () => {
|
||||
it('should mark items as in-season correctly', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Summer - tomato in season, lettuce depends
|
||||
const summerSignal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const tomatoItem = summerSignal.demandItems.find(i => i.produceType === 'tomato');
|
||||
expect(tomatoItem?.inSeason).toBe(true);
|
||||
});
|
||||
|
||||
it('should include seasonal availability info', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
expect(lettuceItem?.seasonalAvailability).toBeDefined();
|
||||
expect(lettuceItem?.seasonalAvailability.spring).toBeDefined();
|
||||
expect(lettuceItem?.seasonalAvailability.summer).toBeDefined();
|
||||
expect(lettuceItem?.seasonalAvailability.fall).toBeDefined();
|
||||
expect(lettuceItem?.seasonalAvailability.winter).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confidence Level', () => {
|
||||
it('should increase confidence with more consumers', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
|
||||
const signal1 = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-2'));
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-3'));
|
||||
|
||||
const signal2 = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
expect(signal2.confidenceLevel).toBeGreaterThan(signal1.confidenceLevel);
|
||||
});
|
||||
|
||||
it('should cap confidence at 100', () => {
|
||||
// Add many consumers
|
||||
for (let i = 0; i < 100; i++) {
|
||||
forecaster.registerPreference(createConsumerPreference(`consumer-${i}`));
|
||||
}
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.confidenceLevel).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function
|
||||
function createConsumerPreference(consumerId: string): ConsumerPreference {
|
||||
return {
|
||||
consumerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
maxDeliveryRadiusKm: 25,
|
||||
},
|
||||
dietaryType: ['omnivore'],
|
||||
allergies: [],
|
||||
dislikes: [],
|
||||
preferredCategories: ['leafy_greens'],
|
||||
preferredItems: [],
|
||||
certificationPreferences: ['organic'],
|
||||
freshnessImportance: 4,
|
||||
priceImportance: 3,
|
||||
sustainabilityImportance: 4,
|
||||
deliveryPreferences: {
|
||||
method: ['home_delivery'],
|
||||
frequency: 'weekly',
|
||||
preferredDays: ['saturday'],
|
||||
},
|
||||
householdSize: 2,
|
||||
weeklyBudget: 100,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
298
__tests__/lib/demand/forecaster.test.ts
Normal file
298
__tests__/lib/demand/forecaster.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* DemandForecaster Tests
|
||||
* Tests for the demand forecasting system
|
||||
*/
|
||||
|
||||
import {
|
||||
DemandForecaster,
|
||||
getDemandForecaster,
|
||||
} from '../../../lib/demand/forecaster';
|
||||
import {
|
||||
ConsumerPreference,
|
||||
SupplyCommitment,
|
||||
DemandSignal,
|
||||
PlantingRecommendation,
|
||||
} from '../../../lib/demand/types';
|
||||
|
||||
describe('DemandForecaster', () => {
|
||||
let forecaster: DemandForecaster;
|
||||
|
||||
beforeEach(() => {
|
||||
forecaster = new DemandForecaster();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create empty forecaster', () => {
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences).toEqual([]);
|
||||
expect(json.supplyCommitments).toEqual([]);
|
||||
expect(json.demandSignals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Consumer Preferences', () => {
|
||||
it('should register consumer preferences', () => {
|
||||
const preference = createConsumerPreference('consumer-1');
|
||||
forecaster.registerPreference(preference);
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should update existing preference', () => {
|
||||
const preference1 = createConsumerPreference('consumer-1');
|
||||
preference1.householdSize = 2;
|
||||
forecaster.registerPreference(preference1);
|
||||
|
||||
const preference2 = createConsumerPreference('consumer-1');
|
||||
preference2.householdSize = 4;
|
||||
forecaster.registerPreference(preference2);
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences.length).toBe(1);
|
||||
expect(json.preferences[0][1].householdSize).toBe(4);
|
||||
});
|
||||
|
||||
it('should register multiple consumers', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-2'));
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-3'));
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.preferences.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supply Commitments', () => {
|
||||
it('should register supply commitment', () => {
|
||||
const commitment = createSupplyCommitment('grower-1', 'lettuce', 50);
|
||||
forecaster.registerSupply(commitment);
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.supplyCommitments.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should track multiple supply commitments', () => {
|
||||
forecaster.registerSupply(createSupplyCommitment('grower-1', 'lettuce', 50));
|
||||
forecaster.registerSupply(createSupplyCommitment('grower-2', 'tomato', 100));
|
||||
|
||||
const json = forecaster.toJSON() as any;
|
||||
expect(json.supplyCommitments.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Demand Signal Generation', () => {
|
||||
it('should generate demand signal for region', () => {
|
||||
// Add consumers
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1', 40.7, -74.0));
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-2', 40.71, -74.01));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 50, 'New York Metro', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.id).toBeDefined();
|
||||
expect(signal.region.name).toBe('New York Metro');
|
||||
expect(signal.totalConsumers).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter consumers by region radius', () => {
|
||||
// Consumer inside radius
|
||||
forecaster.registerPreference(createConsumerPreference('inside', 40.7, -74.0));
|
||||
|
||||
// Consumer outside radius (far away)
|
||||
forecaster.registerPreference(createConsumerPreference('outside', 35.0, -80.0));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 10, 'Small Region', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.totalConsumers).toBe(1);
|
||||
});
|
||||
|
||||
it('should aggregate demand items', () => {
|
||||
const pref1 = createConsumerPreference('consumer-1');
|
||||
pref1.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'preferred', weeklyQuantity: 2, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref1);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test Region', 'summer'
|
||||
);
|
||||
|
||||
expect(signal.demandItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should calculate supply status', () => {
|
||||
// Add consumer demand
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
|
||||
// No supply - should show shortage
|
||||
let signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
expect(['shortage', 'critical', 'balanced']).toContain(signal.supplyStatus);
|
||||
});
|
||||
|
||||
it('should include seasonal period', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'winter'
|
||||
);
|
||||
|
||||
expect(signal.seasonalPeriod).toBe('winter');
|
||||
});
|
||||
|
||||
it('should calculate weekly demand correctly', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.householdSize = 4;
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 2, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
const signal = forecaster.generateDemandSignal(
|
||||
40.7, -74.0, 100, 'Test', 'summer'
|
||||
);
|
||||
|
||||
const lettuceItem = signal.demandItems.find(i => i.produceType === 'lettuce');
|
||||
// Weekly demand = weeklyQuantity * householdSize = 2 * 4 = 8
|
||||
expect(lettuceItem?.weeklyDemandKg).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Demand Forecasting', () => {
|
||||
it('should generate forecast for region', () => {
|
||||
// Setup some historical data
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 100, 'Test Region', 'summer');
|
||||
|
||||
const forecast = forecaster.generateForecast('Test Region', 12);
|
||||
|
||||
expect(forecast.id).toBeDefined();
|
||||
expect(forecast.region).toBe('Test Region');
|
||||
expect(forecast.forecasts).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include confidence intervals', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 100, 'Test', 'summer');
|
||||
|
||||
const forecast = forecaster.generateForecast('Test', 12);
|
||||
|
||||
forecast.forecasts.forEach(f => {
|
||||
expect(f.confidenceInterval.low).toBeLessThanOrEqual(f.predictedDemandKg);
|
||||
expect(f.confidenceInterval.high).toBeGreaterThanOrEqual(f.predictedDemandKg);
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify trends', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 100, 'Test', 'summer');
|
||||
|
||||
const forecast = forecaster.generateForecast('Test', 12);
|
||||
|
||||
forecast.forecasts.forEach(f => {
|
||||
expect(['increasing', 'stable', 'decreasing']).toContain(f.trend);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton', () => {
|
||||
it('should return same instance from getDemandForecaster', () => {
|
||||
const forecaster1 = getDemandForecaster();
|
||||
const forecaster2 = getDemandForecaster();
|
||||
expect(forecaster1).toBe(forecaster2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization', () => {
|
||||
it('should export to JSON', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
|
||||
const json = forecaster.toJSON();
|
||||
expect(json).toHaveProperty('preferences');
|
||||
expect(json).toHaveProperty('supplyCommitments');
|
||||
expect(json).toHaveProperty('demandSignals');
|
||||
});
|
||||
|
||||
it('should import from JSON', () => {
|
||||
forecaster.registerPreference(createConsumerPreference('consumer-1'));
|
||||
forecaster.registerSupply(createSupplyCommitment('grower-1', 'lettuce', 50));
|
||||
|
||||
const json = forecaster.toJSON();
|
||||
const restored = DemandForecaster.fromJSON(json);
|
||||
|
||||
const restoredJson = restored.toJSON() as any;
|
||||
expect(restoredJson.preferences.length).toBe(1);
|
||||
expect(restoredJson.supplyCommitments.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function createConsumerPreference(
|
||||
consumerId: string,
|
||||
lat: number = 40.7128,
|
||||
lon: number = -74.006
|
||||
): ConsumerPreference {
|
||||
return {
|
||||
consumerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
location: {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
maxDeliveryRadiusKm: 25,
|
||||
city: 'New York',
|
||||
},
|
||||
dietaryType: ['omnivore'],
|
||||
allergies: [],
|
||||
dislikes: [],
|
||||
preferredCategories: ['leafy_greens', 'nightshades'],
|
||||
preferredItems: [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1, seasonalOnly: false },
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'preferred', weeklyQuantity: 2, seasonalOnly: false },
|
||||
],
|
||||
certificationPreferences: ['organic', 'local'],
|
||||
freshnessImportance: 5,
|
||||
priceImportance: 3,
|
||||
sustainabilityImportance: 4,
|
||||
deliveryPreferences: {
|
||||
method: ['home_delivery'],
|
||||
frequency: 'weekly',
|
||||
preferredDays: ['saturday'],
|
||||
},
|
||||
householdSize: 3,
|
||||
weeklyBudget: 100,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
|
||||
function createSupplyCommitment(
|
||||
growerId: string,
|
||||
produceType: string,
|
||||
quantity: number
|
||||
): SupplyCommitment {
|
||||
return {
|
||||
id: `supply-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
growerId,
|
||||
timestamp: new Date().toISOString(),
|
||||
produceType,
|
||||
committedQuantityKg: quantity,
|
||||
availableFrom: new Date().toISOString(),
|
||||
availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
pricePerKg: 5,
|
||||
currency: 'USD',
|
||||
minimumOrderKg: 1,
|
||||
certifications: ['organic'],
|
||||
freshnessGuaranteeHours: 48,
|
||||
deliveryRadiusKm: 50,
|
||||
deliveryMethods: ['grower_delivery', 'customer_pickup'],
|
||||
status: 'available',
|
||||
remainingKg: quantity,
|
||||
};
|
||||
}
|
||||
451
__tests__/lib/demand/recommendations.test.ts
Normal file
451
__tests__/lib/demand/recommendations.test.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
/**
|
||||
* Recommendation Logic Tests
|
||||
* Tests for planting recommendation generation
|
||||
*/
|
||||
|
||||
import {
|
||||
DemandForecaster,
|
||||
} from '../../../lib/demand/forecaster';
|
||||
import {
|
||||
ConsumerPreference,
|
||||
PlantingRecommendation,
|
||||
RiskFactor,
|
||||
} from '../../../lib/demand/types';
|
||||
|
||||
describe('Planting Recommendations', () => {
|
||||
let forecaster: DemandForecaster;
|
||||
|
||||
beforeEach(() => {
|
||||
forecaster = new DemandForecaster();
|
||||
});
|
||||
|
||||
describe('Recommendation Generation', () => {
|
||||
it('should generate recommendations based on demand signals', () => {
|
||||
// Create demand
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Generate demand signal
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test Region', 'spring');
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1',
|
||||
40.7,
|
||||
-74.0,
|
||||
50,
|
||||
100, // 100 sqm available
|
||||
'spring'
|
||||
);
|
||||
|
||||
expect(recommendations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include recommendation details', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
const rec = recommendations[0];
|
||||
expect(rec.id).toBeDefined();
|
||||
expect(rec.growerId).toBe('grower-1');
|
||||
expect(rec.produceType).toBeDefined();
|
||||
expect(rec.recommendedQuantity).toBeGreaterThan(0);
|
||||
expect(rec.expectedYieldKg).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not exceed available space', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 1000, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const availableSpace = 50; // Only 50 sqm
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, availableSpace, 'spring'
|
||||
);
|
||||
|
||||
const totalSpace = recommendations.reduce((sum, r) => sum + r.recommendedQuantity, 0);
|
||||
expect(totalSpace).toBeLessThanOrEqual(availableSpace);
|
||||
});
|
||||
|
||||
it('should prioritize high-demand produce', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 50, seasonalOnly: false },
|
||||
{ produceType: 'spinach', category: 'leafy_greens', priority: 'occasional', weeklyQuantity: 5, seasonalOnly: false },
|
||||
];
|
||||
pref.householdSize = 1;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 30, 'spring' // Limited space
|
||||
);
|
||||
|
||||
// Should prioritize lettuce (high demand) over spinach
|
||||
if (recommendations.length > 0) {
|
||||
expect(recommendations[0].produceType).toBe('lettuce');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timing Calculations', () => {
|
||||
it('should include planting and harvest dates', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
const rec = recommendations[0];
|
||||
expect(rec.plantByDate).toBeDefined();
|
||||
expect(rec.expectedHarvestStart).toBeDefined();
|
||||
expect(rec.expectedHarvestEnd).toBeDefined();
|
||||
expect(rec.growingDays).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should calculate harvest date based on growing days', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
const rec = recommendations[0];
|
||||
const plantDate = new Date(rec.plantByDate);
|
||||
const harvestStart = new Date(rec.expectedHarvestStart);
|
||||
|
||||
const daysDiff = (harvestStart.getTime() - plantDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
expect(daysDiff).toBeCloseTo(rec.growingDays, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Financial Projections', () => {
|
||||
it('should calculate projected revenue', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
pref.weeklyBudget = 100;
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
const rec = recommendations[0];
|
||||
expect(rec.projectedPricePerKg).toBeGreaterThan(0);
|
||||
expect(rec.projectedRevenue).toBeGreaterThan(0);
|
||||
expect(rec.projectedRevenue).toBeCloseTo(
|
||||
rec.expectedYieldKg * rec.projectedPricePerKg,
|
||||
-1 // Allow some rounding
|
||||
);
|
||||
});
|
||||
|
||||
it('should include market confidence', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
const rec = recommendations[0];
|
||||
expect(rec.marketConfidence).toBeGreaterThanOrEqual(0);
|
||||
expect(rec.marketConfidence).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Risk Assessment', () => {
|
||||
it('should include risk factors', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'summer');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'summer'
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
expect(rec.riskFactors).toBeDefined();
|
||||
expect(Array.isArray(rec.riskFactors)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should calculate overall risk level', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
expect(['low', 'medium', 'high']).toContain(rec.overallRisk);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include risk mitigation suggestions', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'must_have', weeklyQuantity: 100, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'summer');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 10, 'summer' // Small space for large demand
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
rec.riskFactors.forEach(risk => {
|
||||
if (risk.mitigationSuggestion) {
|
||||
expect(typeof risk.mitigationSuggestion).toBe('string');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Seasonal Filtering', () => {
|
||||
it('should only recommend in-season produce', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
{ produceType: 'tomato', category: 'nightshades', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
// Winter - tomato is not in season
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'winter');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'winter'
|
||||
);
|
||||
|
||||
// Tomato should not be recommended in winter
|
||||
const tomatoRec = recommendations.find(r => r.produceType === 'tomato');
|
||||
expect(tomatoRec).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Demand Signal Matching', () => {
|
||||
it('should filter signals by delivery radius', () => {
|
||||
// Consumer far from grower
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.location = { latitude: 35.0, longitude: -80.0, maxDeliveryRadiusKm: 10 };
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 100, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(35.0, -80.0, 10, 'Distant Region', 'spring');
|
||||
|
||||
// Grower is far from demand signal
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1',
|
||||
40.7, // New York area
|
||||
-74.0,
|
||||
25, // Only 25km delivery radius
|
||||
100,
|
||||
'spring'
|
||||
);
|
||||
|
||||
// Should have no recommendations because demand is too far
|
||||
expect(recommendations.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should match signals from multiple regions', () => {
|
||||
// Multiple consumers in nearby regions
|
||||
const pref1 = createConsumerPreference('consumer-1');
|
||||
pref1.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref1);
|
||||
|
||||
const pref2 = createConsumerPreference('consumer-2');
|
||||
pref2.location = { latitude: 40.72, longitude: -74.01, maxDeliveryRadiusKm: 25 };
|
||||
pref2.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref2);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 20, 'Region 1', 'spring');
|
||||
forecaster.generateDemandSignal(40.75, -74.05, 20, 'Region 2', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
// Should aggregate demand from both regions
|
||||
expect(recommendations.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Yield Calculations', () => {
|
||||
it('should calculate expected yield based on area', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
// Lettuce yields about 4 kg/sqm according to SEASONAL_DATA
|
||||
expect(rec.expectedYieldKg).toBeGreaterThan(0);
|
||||
expect(rec.yieldConfidence).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include yield confidence', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
expect(rec.yieldConfidence).toBeGreaterThanOrEqual(0);
|
||||
expect(rec.yieldConfidence).toBeLessThanOrEqual(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Explanation', () => {
|
||||
it('should include human-readable explanation', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
expect(rec.explanation).toBeDefined();
|
||||
expect(rec.explanation.length).toBeGreaterThan(20);
|
||||
expect(rec.explanation).toContain('demand signal');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include demand signal IDs', () => {
|
||||
const pref = createConsumerPreference('consumer-1');
|
||||
pref.preferredItems = [
|
||||
{ produceType: 'lettuce', category: 'leafy_greens', priority: 'must_have', weeklyQuantity: 10, seasonalOnly: false },
|
||||
];
|
||||
forecaster.registerPreference(pref);
|
||||
|
||||
forecaster.generateDemandSignal(40.7, -74.0, 50, 'Test', 'spring');
|
||||
|
||||
const recommendations = forecaster.generatePlantingRecommendations(
|
||||
'grower-1', 40.7, -74.0, 50, 100, 'spring'
|
||||
);
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
expect(rec.demandSignalIds).toBeDefined();
|
||||
expect(rec.demandSignalIds.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function
|
||||
function createConsumerPreference(consumerId: string): ConsumerPreference {
|
||||
return {
|
||||
consumerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
maxDeliveryRadiusKm: 25,
|
||||
},
|
||||
dietaryType: ['omnivore'],
|
||||
allergies: [],
|
||||
dislikes: [],
|
||||
preferredCategories: ['leafy_greens'],
|
||||
preferredItems: [],
|
||||
certificationPreferences: ['organic'],
|
||||
freshnessImportance: 4,
|
||||
priceImportance: 3,
|
||||
sustainabilityImportance: 4,
|
||||
deliveryPreferences: {
|
||||
method: ['home_delivery'],
|
||||
frequency: 'weekly',
|
||||
preferredDays: ['saturday'],
|
||||
},
|
||||
householdSize: 2,
|
||||
weeklyBudget: 100,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
361
__tests__/lib/transport/carbon.test.ts
Normal file
361
__tests__/lib/transport/carbon.test.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* Carbon Calculation Tests
|
||||
* Tests for carbon footprint calculation logic
|
||||
*/
|
||||
|
||||
import { TransportChain } from '../../../lib/transport/tracker';
|
||||
import {
|
||||
CARBON_FACTORS,
|
||||
TransportMethod,
|
||||
TransportLocation,
|
||||
} from '../../../lib/transport/types';
|
||||
|
||||
describe('Carbon Footprint Calculation', () => {
|
||||
let chain: TransportChain;
|
||||
|
||||
beforeEach(() => {
|
||||
chain = new TransportChain(1);
|
||||
});
|
||||
|
||||
describe('CARBON_FACTORS', () => {
|
||||
it('should have zero emissions for walking', () => {
|
||||
expect(CARBON_FACTORS.walking).toBe(0);
|
||||
});
|
||||
|
||||
it('should have zero emissions for bicycle', () => {
|
||||
expect(CARBON_FACTORS.bicycle).toBe(0);
|
||||
});
|
||||
|
||||
it('should have low emissions for electric vehicles', () => {
|
||||
expect(CARBON_FACTORS.electric_vehicle).toBeLessThan(CARBON_FACTORS.gasoline_vehicle);
|
||||
expect(CARBON_FACTORS.electric_truck).toBeLessThan(CARBON_FACTORS.diesel_truck);
|
||||
});
|
||||
|
||||
it('should have high emissions for air transport', () => {
|
||||
expect(CARBON_FACTORS.air).toBeGreaterThan(CARBON_FACTORS.diesel_truck);
|
||||
expect(CARBON_FACTORS.air).toBeGreaterThan(CARBON_FACTORS.ship);
|
||||
});
|
||||
|
||||
it('should have moderate emissions for refrigerated trucks', () => {
|
||||
expect(CARBON_FACTORS.refrigerated_truck).toBeGreaterThan(CARBON_FACTORS.diesel_truck);
|
||||
});
|
||||
|
||||
it('should have lowest emissions for rail and ship', () => {
|
||||
expect(CARBON_FACTORS.rail).toBeLessThan(CARBON_FACTORS.diesel_truck);
|
||||
expect(CARBON_FACTORS.ship).toBeLessThan(CARBON_FACTORS.diesel_truck);
|
||||
});
|
||||
|
||||
it('should define all transport methods', () => {
|
||||
const methods: TransportMethod[] = [
|
||||
'walking', 'bicycle', 'electric_vehicle', 'hybrid_vehicle',
|
||||
'gasoline_vehicle', 'diesel_truck', 'electric_truck',
|
||||
'refrigerated_truck', 'rail', 'ship', 'air', 'drone',
|
||||
'local_delivery', 'customer_pickup'
|
||||
];
|
||||
|
||||
methods.forEach(method => {
|
||||
expect(CARBON_FACTORS[method]).toBeDefined();
|
||||
expect(typeof CARBON_FACTORS[method]).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCarbon', () => {
|
||||
it('should calculate zero carbon for zero distance', () => {
|
||||
const carbon = chain.calculateCarbon('diesel_truck', 0, 10);
|
||||
expect(carbon).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate zero carbon for zero weight', () => {
|
||||
const carbon = chain.calculateCarbon('diesel_truck', 100, 0);
|
||||
expect(carbon).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate zero carbon for walking', () => {
|
||||
const carbon = chain.calculateCarbon('walking', 10, 5);
|
||||
expect(carbon).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate correct carbon for known values', () => {
|
||||
// diesel_truck = 0.15 kg CO2 per km per kg cargo
|
||||
const carbon = chain.calculateCarbon('diesel_truck', 100, 10);
|
||||
expect(carbon).toBe(0.15 * 100 * 10);
|
||||
});
|
||||
|
||||
it('should increase linearly with distance', () => {
|
||||
const carbon1 = chain.calculateCarbon('diesel_truck', 50, 10);
|
||||
const carbon2 = chain.calculateCarbon('diesel_truck', 100, 10);
|
||||
expect(carbon2).toBe(carbon1 * 2);
|
||||
});
|
||||
|
||||
it('should increase linearly with weight', () => {
|
||||
const carbon1 = chain.calculateCarbon('diesel_truck', 100, 5);
|
||||
const carbon2 = chain.calculateCarbon('diesel_truck', 100, 10);
|
||||
expect(carbon2).toBe(carbon1 * 2);
|
||||
});
|
||||
|
||||
it('should compare transport methods correctly', () => {
|
||||
const distance = 100;
|
||||
const weight = 10;
|
||||
|
||||
const walking = chain.calculateCarbon('walking', distance, weight);
|
||||
const electric = chain.calculateCarbon('electric_vehicle', distance, weight);
|
||||
const gasoline = chain.calculateCarbon('gasoline_vehicle', distance, weight);
|
||||
const diesel = chain.calculateCarbon('diesel_truck', distance, weight);
|
||||
const air = chain.calculateCarbon('air', distance, weight);
|
||||
|
||||
expect(walking).toBe(0);
|
||||
expect(electric).toBeLessThan(gasoline);
|
||||
expect(gasoline).toBeLessThan(diesel);
|
||||
expect(diesel).toBeLessThan(air);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Distance Calculation', () => {
|
||||
it('should calculate zero distance for same location', () => {
|
||||
const location: TransportLocation = {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
locationType: 'farm',
|
||||
};
|
||||
|
||||
const distance = TransportChain.calculateDistance(location, location);
|
||||
expect(distance).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate correct distance for known locations', () => {
|
||||
// New York to Los Angeles is approximately 3940 km
|
||||
const newYork: TransportLocation = {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
locationType: 'warehouse',
|
||||
};
|
||||
|
||||
const losAngeles: TransportLocation = {
|
||||
latitude: 34.0522,
|
||||
longitude: -118.2437,
|
||||
locationType: 'warehouse',
|
||||
};
|
||||
|
||||
const distance = TransportChain.calculateDistance(newYork, losAngeles);
|
||||
expect(distance).toBeGreaterThan(3900);
|
||||
expect(distance).toBeLessThan(4000);
|
||||
});
|
||||
|
||||
it('should calculate short distances accurately', () => {
|
||||
// Two points 1km apart (approximately)
|
||||
const point1: TransportLocation = {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
locationType: 'farm',
|
||||
};
|
||||
|
||||
const point2: TransportLocation = {
|
||||
latitude: 40.7218, // ~1km north
|
||||
longitude: -74.006,
|
||||
locationType: 'farm',
|
||||
};
|
||||
|
||||
const distance = TransportChain.calculateDistance(point1, point2);
|
||||
expect(distance).toBeGreaterThan(0.9);
|
||||
expect(distance).toBeLessThan(1.1);
|
||||
});
|
||||
|
||||
it('should be symmetric', () => {
|
||||
const pointA: TransportLocation = {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
locationType: 'farm',
|
||||
};
|
||||
|
||||
const pointB: TransportLocation = {
|
||||
latitude: 34.0522,
|
||||
longitude: -118.2437,
|
||||
locationType: 'warehouse',
|
||||
};
|
||||
|
||||
const distanceAB = TransportChain.calculateDistance(pointA, pointB);
|
||||
const distanceBA = TransportChain.calculateDistance(pointB, pointA);
|
||||
|
||||
expect(distanceAB).toBeCloseTo(distanceBA, 5);
|
||||
});
|
||||
|
||||
it('should handle crossing the prime meridian', () => {
|
||||
const london: TransportLocation = {
|
||||
latitude: 51.5074,
|
||||
longitude: -0.1278,
|
||||
locationType: 'warehouse',
|
||||
};
|
||||
|
||||
const paris: TransportLocation = {
|
||||
latitude: 48.8566,
|
||||
longitude: 2.3522,
|
||||
locationType: 'warehouse',
|
||||
};
|
||||
|
||||
const distance = TransportChain.calculateDistance(london, paris);
|
||||
// London to Paris is approximately 344 km
|
||||
expect(distance).toBeGreaterThan(330);
|
||||
expect(distance).toBeLessThan(360);
|
||||
});
|
||||
|
||||
it('should handle crossing the equator', () => {
|
||||
const north: TransportLocation = {
|
||||
latitude: 10,
|
||||
longitude: 0,
|
||||
locationType: 'farm',
|
||||
};
|
||||
|
||||
const south: TransportLocation = {
|
||||
latitude: -10,
|
||||
longitude: 0,
|
||||
locationType: 'farm',
|
||||
};
|
||||
|
||||
const distance = TransportChain.calculateDistance(north, south);
|
||||
// 20 degrees of latitude is approximately 2222 km
|
||||
expect(distance).toBeGreaterThan(2200);
|
||||
expect(distance).toBeLessThan(2250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Carbon Tracking in Events', () => {
|
||||
it('should auto-calculate carbon if not provided', () => {
|
||||
const event = {
|
||||
id: 'test-carbon-auto',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition' as const,
|
||||
fromLocation: { latitude: 40, longitude: -74, locationType: 'seed_bank' as const },
|
||||
toLocation: { latitude: 41, longitude: -74, locationType: 'farm' as const },
|
||||
distanceKm: 100,
|
||||
durationMinutes: 120,
|
||||
transportMethod: 'diesel_truck' as const,
|
||||
carbonFootprintKg: 0, // Will be auto-calculated
|
||||
senderId: 'sender',
|
||||
receiverId: 'receiver',
|
||||
status: 'verified' as const,
|
||||
seedBatchId: 'batch-001',
|
||||
sourceType: 'seed_bank' as const,
|
||||
species: 'Test',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds' as const,
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
const block = chain.recordEvent(event);
|
||||
expect(block.transportEvent.carbonFootprintKg).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should accumulate carbon across chain', () => {
|
||||
const event1 = {
|
||||
id: 'carbon-chain-1',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition' as const,
|
||||
fromLocation: { latitude: 40, longitude: -74, locationType: 'seed_bank' as const },
|
||||
toLocation: { latitude: 41, longitude: -74, locationType: 'farm' as const },
|
||||
distanceKm: 100,
|
||||
durationMinutes: 120,
|
||||
transportMethod: 'diesel_truck' as const,
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'sender',
|
||||
receiverId: 'receiver',
|
||||
status: 'verified' as const,
|
||||
seedBatchId: 'batch-001',
|
||||
sourceType: 'seed_bank' as const,
|
||||
species: 'Test',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds' as const,
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
const event2 = { ...event1, id: 'carbon-chain-2', distanceKm: 200 };
|
||||
|
||||
const block1 = chain.recordEvent(event1);
|
||||
const block2 = chain.recordEvent(event2);
|
||||
|
||||
expect(block2.cumulativeCarbonKg).toBeGreaterThan(block1.cumulativeCarbonKg);
|
||||
expect(block2.cumulativeCarbonKg).toBeCloseTo(
|
||||
block1.cumulativeCarbonKg + block2.transportEvent.carbonFootprintKg,
|
||||
5
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environmental Impact Calculations', () => {
|
||||
it('should calculate carbon savings vs conventional', () => {
|
||||
const userId = 'eco-user';
|
||||
|
||||
const event = {
|
||||
id: 'eco-event',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition' as const,
|
||||
fromLocation: { latitude: 40, longitude: -74, locationType: 'seed_bank' as const },
|
||||
toLocation: { latitude: 40.1, longitude: -74, locationType: 'farm' as const },
|
||||
distanceKm: 10, // Very short local distance
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'bicycle' as const, // Zero carbon
|
||||
carbonFootprintKg: 0,
|
||||
senderId: userId,
|
||||
receiverId: userId,
|
||||
status: 'verified' as const,
|
||||
seedBatchId: 'batch-eco',
|
||||
sourceType: 'seed_bank' as const,
|
||||
species: 'Test',
|
||||
quantity: 1000,
|
||||
quantityUnit: 'grams' as const,
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
chain.recordEvent(event);
|
||||
const impact = chain.getEnvironmentalImpact(userId);
|
||||
|
||||
// Local, zero-carbon transport should save significantly vs conventional
|
||||
expect(impact.comparisonToConventional.carbonSaved).toBeGreaterThan(0);
|
||||
expect(impact.comparisonToConventional.milesSaved).toBeGreaterThan(0);
|
||||
expect(impact.comparisonToConventional.percentageReduction).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should break down impact by transport method', () => {
|
||||
const userId = 'breakdown-user';
|
||||
|
||||
// Record multiple events with different methods
|
||||
const methods: TransportMethod[] = ['bicycle', 'electric_vehicle', 'diesel_truck'];
|
||||
|
||||
methods.forEach((method, i) => {
|
||||
chain.recordEvent({
|
||||
id: `breakdown-${i}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition' as const,
|
||||
fromLocation: { latitude: 40, longitude: -74, locationType: 'seed_bank' as const },
|
||||
toLocation: { latitude: 41, longitude: -74, locationType: 'farm' as const },
|
||||
distanceKm: 50,
|
||||
durationMinutes: 60,
|
||||
transportMethod: method,
|
||||
carbonFootprintKg: 0,
|
||||
senderId: userId,
|
||||
receiverId: userId,
|
||||
status: 'verified' as const,
|
||||
seedBatchId: `batch-${i}`,
|
||||
sourceType: 'seed_bank' as const,
|
||||
species: 'Test',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds' as const,
|
||||
generation: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const impact = chain.getEnvironmentalImpact(userId);
|
||||
|
||||
expect(impact.breakdownByMethod['bicycle']).toBeDefined();
|
||||
expect(impact.breakdownByMethod['electric_vehicle']).toBeDefined();
|
||||
expect(impact.breakdownByMethod['diesel_truck']).toBeDefined();
|
||||
|
||||
// Bicycle should have zero carbon
|
||||
expect(impact.breakdownByMethod['bicycle'].carbon).toBe(0);
|
||||
|
||||
// Diesel should have highest carbon
|
||||
expect(impact.breakdownByMethod['diesel_truck'].carbon)
|
||||
.toBeGreaterThan(impact.breakdownByMethod['electric_vehicle'].carbon);
|
||||
});
|
||||
});
|
||||
});
|
||||
471
__tests__/lib/transport/tracker.test.ts
Normal file
471
__tests__/lib/transport/tracker.test.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
/**
|
||||
* TransportChain Tests
|
||||
* Tests for the transport tracking blockchain implementation
|
||||
*/
|
||||
|
||||
import {
|
||||
TransportChain,
|
||||
getTransportChain,
|
||||
setTransportChain,
|
||||
} from '../../../lib/transport/tracker';
|
||||
import {
|
||||
SeedAcquisitionEvent,
|
||||
PlantingEvent,
|
||||
GrowingTransportEvent,
|
||||
HarvestEvent,
|
||||
SeedSavingEvent,
|
||||
TransportLocation,
|
||||
CARBON_FACTORS,
|
||||
} from '../../../lib/transport/types';
|
||||
|
||||
describe('TransportChain', () => {
|
||||
let chain: TransportChain;
|
||||
|
||||
beforeEach(() => {
|
||||
chain = new TransportChain(2); // Lower difficulty for faster tests
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create genesis block on initialization', () => {
|
||||
expect(chain.chain.length).toBe(1);
|
||||
expect(chain.chain[0].index).toBe(0);
|
||||
expect(chain.chain[0].previousHash).toBe('0');
|
||||
expect(chain.chain[0].transportEvent.eventType).toBe('seed_acquisition');
|
||||
});
|
||||
|
||||
it('should set default difficulty', () => {
|
||||
const defaultChain = new TransportChain();
|
||||
expect(defaultChain.difficulty).toBe(3);
|
||||
});
|
||||
|
||||
it('should allow custom difficulty', () => {
|
||||
expect(chain.difficulty).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recording Events', () => {
|
||||
it('should record seed acquisition event', () => {
|
||||
const event: SeedAcquisitionEvent = createSeedAcquisitionEvent();
|
||||
const block = chain.recordEvent(event);
|
||||
|
||||
expect(block.index).toBe(1);
|
||||
expect(block.transportEvent.eventType).toBe('seed_acquisition');
|
||||
expect(chain.chain.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should record planting event', () => {
|
||||
const seedEvent = createSeedAcquisitionEvent();
|
||||
chain.recordEvent(seedEvent);
|
||||
|
||||
const plantingEvent: PlantingEvent = createPlantingEvent();
|
||||
const block = chain.recordEvent(plantingEvent);
|
||||
|
||||
expect(block.transportEvent.eventType).toBe('planting');
|
||||
expect((block.transportEvent as PlantingEvent).plantIds.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should record growing transport event', () => {
|
||||
const seedEvent = createSeedAcquisitionEvent();
|
||||
chain.recordEvent(seedEvent);
|
||||
|
||||
const plantingEvent = createPlantingEvent();
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
const growingEvent: GrowingTransportEvent = {
|
||||
id: 'growing-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'growing_transport',
|
||||
fromLocation: createLocation('greenhouse'),
|
||||
toLocation: createLocation('farm'),
|
||||
distanceKm: 5,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
plantIds: ['plant-001', 'plant-002'],
|
||||
reason: 'transplant',
|
||||
plantStage: 'seedling',
|
||||
handlingMethod: 'potted',
|
||||
rootDisturbance: 'minimal',
|
||||
acclimatizationRequired: true,
|
||||
acclimatizationDays: 3,
|
||||
};
|
||||
|
||||
const block = chain.recordEvent(growingEvent);
|
||||
expect(block.transportEvent.eventType).toBe('growing_transport');
|
||||
});
|
||||
|
||||
it('should record harvest event', () => {
|
||||
const harvestEvent: HarvestEvent = {
|
||||
id: 'harvest-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'harvest',
|
||||
fromLocation: createLocation('farm'),
|
||||
toLocation: createLocation('warehouse'),
|
||||
distanceKm: 10,
|
||||
durationMinutes: 45,
|
||||
transportMethod: 'electric_truck',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'distributor-1',
|
||||
status: 'verified',
|
||||
plantIds: ['plant-001', 'plant-002'],
|
||||
harvestBatchId: 'harvest-batch-001',
|
||||
harvestType: 'full',
|
||||
produceType: 'tomatoes',
|
||||
grossWeight: 10,
|
||||
netWeight: 9.5,
|
||||
weightUnit: 'kg',
|
||||
packagingType: 'crates',
|
||||
temperatureRequired: { min: 10, max: 15, optimal: 12, unit: 'celsius' },
|
||||
shelfLifeHours: 168,
|
||||
seedsSaved: false,
|
||||
};
|
||||
|
||||
const block = chain.recordEvent(harvestEvent);
|
||||
expect(block.transportEvent.eventType).toBe('harvest');
|
||||
expect((block.transportEvent as HarvestEvent).netWeight).toBe(9.5);
|
||||
});
|
||||
|
||||
it('should accumulate carbon footprint across blocks', () => {
|
||||
const event1 = createSeedAcquisitionEvent();
|
||||
event1.distanceKm = 10;
|
||||
event1.transportMethod = 'gasoline_vehicle';
|
||||
const block1 = chain.recordEvent(event1);
|
||||
|
||||
const event2 = createSeedAcquisitionEvent();
|
||||
event2.id = 'seed-002';
|
||||
event2.distanceKm = 20;
|
||||
event2.transportMethod = 'diesel_truck';
|
||||
const block2 = chain.recordEvent(event2);
|
||||
|
||||
expect(block2.cumulativeCarbonKg).toBeGreaterThan(block1.cumulativeCarbonKg);
|
||||
});
|
||||
|
||||
it('should accumulate food miles across blocks', () => {
|
||||
const event1 = createSeedAcquisitionEvent();
|
||||
event1.distanceKm = 10;
|
||||
const block1 = chain.recordEvent(event1);
|
||||
|
||||
const event2 = createSeedAcquisitionEvent();
|
||||
event2.id = 'seed-002';
|
||||
event2.distanceKm = 20;
|
||||
const block2 = chain.recordEvent(event2);
|
||||
|
||||
expect(block2.cumulativeFoodMiles).toBe(
|
||||
block1.cumulativeFoodMiles + 20
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chain Integrity', () => {
|
||||
it('should verify chain integrity', () => {
|
||||
chain.recordEvent(createSeedAcquisitionEvent());
|
||||
chain.recordEvent(createPlantingEvent());
|
||||
|
||||
expect(chain.isChainValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect tampered blocks', () => {
|
||||
chain.recordEvent(createSeedAcquisitionEvent());
|
||||
|
||||
// Tamper with the chain
|
||||
chain.chain[1].transportEvent.distanceKm = 999;
|
||||
|
||||
expect(chain.isChainValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect broken chain links', () => {
|
||||
chain.recordEvent(createSeedAcquisitionEvent());
|
||||
chain.recordEvent(createPlantingEvent());
|
||||
|
||||
// Break the chain link
|
||||
chain.chain[2].previousHash = 'fake-hash';
|
||||
|
||||
expect(chain.isChainValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should maintain proper hash linking', () => {
|
||||
chain.recordEvent(createSeedAcquisitionEvent());
|
||||
chain.recordEvent(createPlantingEvent());
|
||||
|
||||
for (let i = 1; i < chain.chain.length; i++) {
|
||||
expect(chain.chain[i].previousHash).toBe(chain.chain[i - 1].hash);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plant Journey', () => {
|
||||
it('should track plant journey across events', () => {
|
||||
const plantId = 'plant-001';
|
||||
|
||||
// Record planting
|
||||
const plantingEvent = createPlantingEvent();
|
||||
plantingEvent.plantIds = [plantId];
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
// Record growing transport
|
||||
const growingEvent: GrowingTransportEvent = {
|
||||
id: 'growing-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'growing_transport',
|
||||
fromLocation: createLocation('greenhouse'),
|
||||
toLocation: createLocation('farm'),
|
||||
distanceKm: 5,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
plantIds: [plantId],
|
||||
reason: 'transplant',
|
||||
plantStage: 'vegetative',
|
||||
handlingMethod: 'potted',
|
||||
rootDisturbance: 'minimal',
|
||||
acclimatizationRequired: false,
|
||||
};
|
||||
chain.recordEvent(growingEvent);
|
||||
|
||||
const journey = chain.getPlantJourney(plantId);
|
||||
expect(journey).not.toBeNull();
|
||||
expect(journey?.plantId).toBe(plantId);
|
||||
expect(journey?.events.length).toBe(2);
|
||||
expect(journey?.totalFoodMiles).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return null for unknown plant', () => {
|
||||
const journey = chain.getPlantJourney('unknown-plant');
|
||||
expect(journey).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate correct journey metrics', () => {
|
||||
const plantId = 'plant-journey-test';
|
||||
|
||||
const plantingEvent = createPlantingEvent();
|
||||
plantingEvent.plantIds = [plantId];
|
||||
plantingEvent.distanceKm = 10;
|
||||
plantingEvent.durationMinutes = 60;
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
const journey = chain.getPlantJourney(plantId);
|
||||
expect(journey?.totalFoodMiles).toBe(10);
|
||||
expect(journey?.daysInTransit).toBe(0); // 60 min = ~0 days
|
||||
});
|
||||
|
||||
it('should track current stage correctly', () => {
|
||||
const plantId = 'stage-test-plant';
|
||||
|
||||
const plantingEvent = createPlantingEvent();
|
||||
plantingEvent.plantIds = [plantId];
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
let journey = chain.getPlantJourney(plantId);
|
||||
expect(journey?.currentStage).toBe('seedling');
|
||||
|
||||
const growingEvent: GrowingTransportEvent = {
|
||||
id: 'growing-stage',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'growing_transport',
|
||||
fromLocation: createLocation('greenhouse'),
|
||||
toLocation: createLocation('farm'),
|
||||
distanceKm: 1,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
plantIds: [plantId],
|
||||
reason: 'relocation',
|
||||
plantStage: 'flowering',
|
||||
handlingMethod: 'potted',
|
||||
rootDisturbance: 'none',
|
||||
acclimatizationRequired: false,
|
||||
};
|
||||
chain.recordEvent(growingEvent);
|
||||
|
||||
journey = chain.getPlantJourney(plantId);
|
||||
expect(journey?.currentStage).toBe('flowering');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environmental Impact', () => {
|
||||
it('should calculate environmental impact for user', () => {
|
||||
const userId = 'user-impact-test';
|
||||
|
||||
const event = createSeedAcquisitionEvent();
|
||||
event.senderId = userId;
|
||||
event.distanceKm = 50;
|
||||
event.transportMethod = 'diesel_truck';
|
||||
chain.recordEvent(event);
|
||||
|
||||
const impact = chain.getEnvironmentalImpact(userId);
|
||||
expect(impact.totalFoodMiles).toBe(50);
|
||||
expect(impact.totalCarbonKg).toBeGreaterThan(0);
|
||||
expect(impact.breakdownByMethod['diesel_truck']).toBeDefined();
|
||||
expect(impact.breakdownByEventType['seed_acquisition']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should compare to conventional agriculture', () => {
|
||||
const userId = 'compare-test';
|
||||
|
||||
const event = createSeedAcquisitionEvent();
|
||||
event.senderId = userId;
|
||||
event.distanceKm = 10; // Very short distance
|
||||
event.transportMethod = 'bicycle'; // Zero carbon
|
||||
chain.recordEvent(event);
|
||||
|
||||
const impact = chain.getEnvironmentalImpact(userId);
|
||||
expect(impact.comparisonToConventional.milesSaved).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return zero impact for user with no events', () => {
|
||||
const impact = chain.getEnvironmentalImpact('nonexistent-user');
|
||||
expect(impact.totalCarbonKg).toBe(0);
|
||||
expect(impact.totalFoodMiles).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR Code Generation', () => {
|
||||
it('should generate valid QR data for plant', () => {
|
||||
const plantId = 'qr-test-plant';
|
||||
|
||||
const plantingEvent = createPlantingEvent();
|
||||
plantingEvent.plantIds = [plantId];
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
const qrData = chain.generateQRData(plantId, undefined);
|
||||
expect(qrData.plantId).toBe(plantId);
|
||||
expect(qrData.quickLookupUrl).toContain(plantId);
|
||||
expect(qrData.lineageHash).toBeDefined();
|
||||
expect(qrData.verificationCode).toMatch(/^[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
it('should generate QR data for batch', () => {
|
||||
const batchId = 'seed-batch-001';
|
||||
|
||||
const event = createSeedAcquisitionEvent();
|
||||
event.seedBatchId = batchId;
|
||||
chain.recordEvent(event);
|
||||
|
||||
const qrData = chain.generateQRData(undefined, batchId);
|
||||
expect(qrData.batchId).toBe(batchId);
|
||||
expect(qrData.lastEventType).toBe('seed_acquisition');
|
||||
});
|
||||
|
||||
it('should include blockchain address', () => {
|
||||
const qrData = chain.generateQRData('any-id', undefined);
|
||||
expect(qrData.blockchainAddress).toHaveLength(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization', () => {
|
||||
it('should export to JSON', () => {
|
||||
chain.recordEvent(createSeedAcquisitionEvent());
|
||||
|
||||
const json = chain.toJSON();
|
||||
expect(json).toHaveProperty('difficulty');
|
||||
expect(json).toHaveProperty('chain');
|
||||
expect((json as any).chain.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should import from JSON', () => {
|
||||
chain.recordEvent(createSeedAcquisitionEvent());
|
||||
chain.recordEvent(createPlantingEvent());
|
||||
|
||||
const json = chain.toJSON();
|
||||
const restored = TransportChain.fromJSON(json);
|
||||
|
||||
expect(restored.chain.length).toBe(chain.chain.length);
|
||||
expect(restored.isChainValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should rebuild indexes after import', () => {
|
||||
const plantId = 'import-test-plant';
|
||||
const plantingEvent = createPlantingEvent();
|
||||
plantingEvent.plantIds = [plantId];
|
||||
chain.recordEvent(plantingEvent);
|
||||
|
||||
const json = chain.toJSON();
|
||||
const restored = TransportChain.fromJSON(json);
|
||||
|
||||
const journey = restored.getPlantJourney(plantId);
|
||||
expect(journey).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton', () => {
|
||||
it('should return same instance from getTransportChain', () => {
|
||||
const chain1 = getTransportChain();
|
||||
const chain2 = getTransportChain();
|
||||
expect(chain1).toBe(chain2);
|
||||
});
|
||||
|
||||
it('should allow setting custom chain', () => {
|
||||
const customChain = new TransportChain(1);
|
||||
setTransportChain(customChain);
|
||||
expect(getTransportChain()).toBe(customChain);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function createLocation(type: string): TransportLocation {
|
||||
return {
|
||||
latitude: 40.7128 + Math.random() * 0.1,
|
||||
longitude: -74.006 + Math.random() * 0.1,
|
||||
locationType: type as any,
|
||||
facilityName: `Test ${type}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createSeedAcquisitionEvent(): SeedAcquisitionEvent {
|
||||
return {
|
||||
id: `seed-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: createLocation('seed_bank'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 25,
|
||||
durationMinutes: 45,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'seed-bank-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
seedBatchId: 'seed-batch-001',
|
||||
sourceType: 'seed_bank',
|
||||
species: 'Solanum lycopersicum',
|
||||
variety: 'Roma',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
germinationRate: 95,
|
||||
certifications: ['organic', 'heirloom'],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlantingEvent(): PlantingEvent {
|
||||
return {
|
||||
id: `planting-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: createLocation('greenhouse'),
|
||||
toLocation: createLocation('greenhouse'),
|
||||
distanceKm: 0.01,
|
||||
durationMinutes: 5,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-1',
|
||||
receiverId: 'grower-1',
|
||||
status: 'verified',
|
||||
seedBatchId: 'seed-batch-001',
|
||||
plantIds: ['plant-001', 'plant-002'],
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: 2,
|
||||
growingEnvironment: 'greenhouse',
|
||||
};
|
||||
}
|
||||
362
__tests__/lib/transport/types.test.ts
Normal file
362
__tests__/lib/transport/types.test.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* Transport Types Tests
|
||||
* Tests for type validation and consistency
|
||||
*/
|
||||
|
||||
import {
|
||||
TransportLocation,
|
||||
TransportMethod,
|
||||
TransportEventType,
|
||||
PlantStage,
|
||||
CARBON_FACTORS,
|
||||
SeedAcquisitionEvent,
|
||||
PlantingEvent,
|
||||
GrowingTransportEvent,
|
||||
HarvestEvent,
|
||||
ProcessingEvent,
|
||||
DistributionEvent,
|
||||
ConsumerDeliveryEvent,
|
||||
SeedSavingEvent,
|
||||
SeedSharingEvent,
|
||||
TransportBlock,
|
||||
PlantJourney,
|
||||
EnvironmentalImpact,
|
||||
TransportQRData,
|
||||
} from '../../../lib/transport/types';
|
||||
|
||||
describe('Transport Types Validation', () => {
|
||||
describe('TransportLocation', () => {
|
||||
it('should accept valid location types', () => {
|
||||
const validTypes = [
|
||||
'farm', 'greenhouse', 'vertical_farm', 'warehouse',
|
||||
'hub', 'market', 'consumer', 'seed_bank', 'other'
|
||||
];
|
||||
|
||||
validTypes.forEach(locationType => {
|
||||
const location: TransportLocation = {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
locationType: locationType as any,
|
||||
};
|
||||
expect(location.locationType).toBe(locationType);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have required latitude and longitude', () => {
|
||||
const location: TransportLocation = {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
locationType: 'farm',
|
||||
};
|
||||
expect(location.latitude).toBeDefined();
|
||||
expect(location.longitude).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept optional fields', () => {
|
||||
const location: TransportLocation = {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
address: '123 Farm Lane',
|
||||
city: 'New York',
|
||||
region: 'NY',
|
||||
country: 'USA',
|
||||
postalCode: '10001',
|
||||
locationType: 'farm',
|
||||
facilityId: 'facility-001',
|
||||
facilityName: 'Green Acres Farm',
|
||||
};
|
||||
|
||||
expect(location.address).toBe('123 Farm Lane');
|
||||
expect(location.facilityName).toBe('Green Acres Farm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransportMethod', () => {
|
||||
it('should define all transport methods', () => {
|
||||
const methods: TransportMethod[] = [
|
||||
'walking', 'bicycle', 'electric_vehicle', 'hybrid_vehicle',
|
||||
'gasoline_vehicle', 'diesel_truck', 'electric_truck',
|
||||
'refrigerated_truck', 'rail', 'ship', 'air', 'drone',
|
||||
'local_delivery', 'customer_pickup'
|
||||
];
|
||||
|
||||
// All methods should have corresponding carbon factors
|
||||
methods.forEach(method => {
|
||||
expect(CARBON_FACTORS[method]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlantStage', () => {
|
||||
it('should cover complete plant lifecycle', () => {
|
||||
const stages: PlantStage[] = [
|
||||
'seed', 'germinating', 'seedling', 'vegetative',
|
||||
'flowering', 'fruiting', 'mature', 'harvesting',
|
||||
'post_harvest', 'seed_saving'
|
||||
];
|
||||
|
||||
// Verify all stages are valid strings
|
||||
stages.forEach(stage => {
|
||||
expect(typeof stage).toBe('string');
|
||||
expect(stage.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransportEventType', () => {
|
||||
it('should cover complete transport event types', () => {
|
||||
const eventTypes: TransportEventType[] = [
|
||||
'seed_acquisition', 'planting', 'growing_transport',
|
||||
'harvest', 'processing', 'distribution',
|
||||
'consumer_delivery', 'seed_saving', 'seed_sharing'
|
||||
];
|
||||
|
||||
expect(eventTypes.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Type Structures', () => {
|
||||
it('should validate SeedAcquisitionEvent', () => {
|
||||
const event: SeedAcquisitionEvent = {
|
||||
id: 'seed-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: { latitude: 0, longitude: 0, locationType: 'seed_bank' },
|
||||
toLocation: { latitude: 0, longitude: 0, locationType: 'farm' },
|
||||
distanceKm: 10,
|
||||
durationMinutes: 30,
|
||||
transportMethod: 'electric_vehicle',
|
||||
carbonFootprintKg: 0.5,
|
||||
senderId: 'sender-001',
|
||||
receiverId: 'receiver-001',
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch-001',
|
||||
sourceType: 'seed_bank',
|
||||
species: 'Solanum lycopersicum',
|
||||
quantity: 100,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
expect(event.eventType).toBe('seed_acquisition');
|
||||
expect(event.seedBatchId).toBeDefined();
|
||||
expect(event.species).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate PlantingEvent', () => {
|
||||
const event: PlantingEvent = {
|
||||
id: 'planting-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'planting',
|
||||
fromLocation: { latitude: 0, longitude: 0, locationType: 'greenhouse' },
|
||||
toLocation: { latitude: 0, longitude: 0, locationType: 'greenhouse' },
|
||||
distanceKm: 0,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-001',
|
||||
receiverId: 'grower-001',
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch-001',
|
||||
plantIds: ['plant-001', 'plant-002'],
|
||||
plantingMethod: 'indoor_start',
|
||||
quantityPlanted: 2,
|
||||
growingEnvironment: 'greenhouse',
|
||||
};
|
||||
|
||||
expect(event.eventType).toBe('planting');
|
||||
expect(event.plantIds.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should validate HarvestEvent', () => {
|
||||
const event: HarvestEvent = {
|
||||
id: 'harvest-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'harvest',
|
||||
fromLocation: { latitude: 0, longitude: 0, locationType: 'farm' },
|
||||
toLocation: { latitude: 0, longitude: 0, locationType: 'warehouse' },
|
||||
distanceKm: 5,
|
||||
durationMinutes: 20,
|
||||
transportMethod: 'electric_truck',
|
||||
carbonFootprintKg: 0.1,
|
||||
senderId: 'grower-001',
|
||||
receiverId: 'processor-001',
|
||||
status: 'verified',
|
||||
plantIds: ['plant-001'],
|
||||
harvestBatchId: 'harvest-001',
|
||||
harvestType: 'full',
|
||||
produceType: 'tomatoes',
|
||||
grossWeight: 10,
|
||||
netWeight: 9.5,
|
||||
weightUnit: 'kg',
|
||||
packagingType: 'crates',
|
||||
temperatureRequired: { min: 10, max: 15, optimal: 12, unit: 'celsius' },
|
||||
shelfLifeHours: 168,
|
||||
seedsSaved: true,
|
||||
seedBatchIdCreated: 'new-seed-batch-001',
|
||||
};
|
||||
|
||||
expect(event.eventType).toBe('harvest');
|
||||
expect(event.seedsSaved).toBe(true);
|
||||
expect(event.seedBatchIdCreated).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate SeedSavingEvent', () => {
|
||||
const event: SeedSavingEvent = {
|
||||
id: 'save-001',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_saving',
|
||||
fromLocation: { latitude: 0, longitude: 0, locationType: 'farm' },
|
||||
toLocation: { latitude: 0, longitude: 0, locationType: 'seed_bank' },
|
||||
distanceKm: 1,
|
||||
durationMinutes: 10,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'grower-001',
|
||||
receiverId: 'grower-001',
|
||||
status: 'verified',
|
||||
parentPlantIds: ['plant-001', 'plant-002'],
|
||||
newSeedBatchId: 'new-batch-001',
|
||||
collectionMethod: 'dry_seed',
|
||||
seedCount: 500,
|
||||
storageConditions: {
|
||||
temperature: 10,
|
||||
humidity: 30,
|
||||
lightExposure: 'dark',
|
||||
containerType: 'jar',
|
||||
desiccant: true,
|
||||
estimatedViability: 5,
|
||||
},
|
||||
storageLocationId: 'storage-001',
|
||||
newGenerationNumber: 2,
|
||||
availableForSharing: true,
|
||||
};
|
||||
|
||||
expect(event.eventType).toBe('seed_saving');
|
||||
expect(event.newGenerationNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransportBlock', () => {
|
||||
it('should have all required blockchain fields', () => {
|
||||
const block: TransportBlock = {
|
||||
index: 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
transportEvent: {
|
||||
id: 'test',
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'seed_acquisition',
|
||||
fromLocation: { latitude: 0, longitude: 0, locationType: 'seed_bank' },
|
||||
toLocation: { latitude: 0, longitude: 0, locationType: 'farm' },
|
||||
distanceKm: 0,
|
||||
durationMinutes: 0,
|
||||
transportMethod: 'walking',
|
||||
carbonFootprintKg: 0,
|
||||
senderId: 'a',
|
||||
receiverId: 'b',
|
||||
status: 'verified',
|
||||
seedBatchId: 'batch',
|
||||
sourceType: 'seed_bank',
|
||||
species: 'test',
|
||||
quantity: 1,
|
||||
quantityUnit: 'seeds',
|
||||
generation: 0,
|
||||
},
|
||||
previousHash: '0'.repeat(64),
|
||||
hash: 'a'.repeat(64),
|
||||
nonce: 12345,
|
||||
cumulativeCarbonKg: 0,
|
||||
cumulativeFoodMiles: 0,
|
||||
chainLength: 2,
|
||||
};
|
||||
|
||||
expect(block.index).toBeDefined();
|
||||
expect(block.previousHash.length).toBe(64);
|
||||
expect(block.hash.length).toBe(64);
|
||||
expect(block.nonce).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlantJourney', () => {
|
||||
it('should track complete journey data', () => {
|
||||
const journey: PlantJourney = {
|
||||
plantId: 'plant-001',
|
||||
seedBatchOrigin: 'batch-001',
|
||||
currentCustodian: 'grower-001',
|
||||
currentLocation: { latitude: 40, longitude: -74, locationType: 'farm' },
|
||||
currentStage: 'vegetative',
|
||||
events: [],
|
||||
totalFoodMiles: 25.5,
|
||||
totalCarbonKg: 1.2,
|
||||
daysInTransit: 2,
|
||||
daysGrowing: 45,
|
||||
generation: 1,
|
||||
ancestorPlantIds: ['parent-001'],
|
||||
descendantSeedBatches: ['future-batch-001'],
|
||||
};
|
||||
|
||||
expect(journey.plantId).toBe('plant-001');
|
||||
expect(journey.totalFoodMiles).toBe(25.5);
|
||||
expect(journey.generation).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnvironmentalImpact', () => {
|
||||
it('should have comparison metrics', () => {
|
||||
const impact: EnvironmentalImpact = {
|
||||
totalCarbonKg: 10,
|
||||
totalFoodMiles: 100,
|
||||
carbonPerKgProduce: 0.5,
|
||||
milesPerKgProduce: 5,
|
||||
breakdownByMethod: {},
|
||||
breakdownByEventType: {},
|
||||
comparisonToConventional: {
|
||||
carbonSaved: 50,
|
||||
milesSaved: 1400,
|
||||
percentageReduction: 83,
|
||||
},
|
||||
};
|
||||
|
||||
expect(impact.comparisonToConventional.percentageReduction).toBe(83);
|
||||
expect(impact.comparisonToConventional.milesSaved).toBe(1400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransportQRData', () => {
|
||||
it('should have verification data', () => {
|
||||
const qrData: TransportQRData = {
|
||||
plantId: 'plant-001',
|
||||
blockchainAddress: '0x' + 'a'.repeat(40),
|
||||
quickLookupUrl: 'https://example.com/track/plant-001',
|
||||
lineageHash: 'abc123',
|
||||
currentCustodian: 'grower-001',
|
||||
lastEventType: 'planting',
|
||||
lastEventTimestamp: new Date().toISOString(),
|
||||
verificationCode: 'ABCD1234',
|
||||
};
|
||||
|
||||
expect(qrData.verificationCode.length).toBe(8);
|
||||
expect(qrData.quickLookupUrl).toContain('plant-001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Values', () => {
|
||||
it('should have valid event statuses', () => {
|
||||
const statuses: Array<'pending' | 'in_transit' | 'delivered' | 'verified' | 'disputed'> = [
|
||||
'pending', 'in_transit', 'delivered', 'verified', 'disputed'
|
||||
];
|
||||
|
||||
expect(statuses.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certification Types', () => {
|
||||
it('should define seed certifications', () => {
|
||||
const certifications: Array<'organic' | 'non_gmo' | 'heirloom' | 'certified_seed' | 'biodynamic'> = [
|
||||
'organic', 'non_gmo', 'heirloom', 'certified_seed', 'biodynamic'
|
||||
];
|
||||
|
||||
expect(certifications.length).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
530
__tests__/lib/vertical-farming/controller.test.ts
Normal file
530
__tests__/lib/vertical-farming/controller.test.ts
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
/**
|
||||
* 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',
|
||||
};
|
||||
}
|
||||
593
__tests__/lib/vertical-farming/environment.test.ts
Normal file
593
__tests__/lib/vertical-farming/environment.test.ts
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
/**
|
||||
* Environment Alert Tests
|
||||
* Tests for environment monitoring and alert generation
|
||||
*/
|
||||
|
||||
import {
|
||||
VerticalFarmController,
|
||||
} from '../../../lib/vertical-farming/controller';
|
||||
import {
|
||||
VerticalFarm,
|
||||
GrowingZone,
|
||||
ZoneEnvironmentReadings,
|
||||
EnvironmentAlert,
|
||||
} from '../../../lib/vertical-farming/types';
|
||||
|
||||
describe('Environment Alerts', () => {
|
||||
let controller: VerticalFarmController;
|
||||
|
||||
beforeEach(() => {
|
||||
controller = new VerticalFarmController();
|
||||
});
|
||||
|
||||
describe('Temperature Alerts', () => {
|
||||
it('should detect low temperature', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 15, // Below min of 18
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
expect(tempAlert).toBeDefined();
|
||||
expect(tempAlert?.type).toBe('low');
|
||||
});
|
||||
|
||||
it('should detect high temperature', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 30, // Above max of 24
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
expect(tempAlert).toBeDefined();
|
||||
expect(tempAlert?.type).toBe('high');
|
||||
});
|
||||
|
||||
it('should detect critical low temperature', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 10, // More than 5 below min
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
expect(tempAlert?.type).toBe('critical_low');
|
||||
});
|
||||
|
||||
it('should detect critical high temperature', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 35, // More than 5 above max
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
expect(tempAlert?.type).toBe('critical_high');
|
||||
});
|
||||
|
||||
it('should not alert for normal temperature', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 21, // Within range
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
expect(tempAlert).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Humidity Alerts', () => {
|
||||
it('should detect low humidity', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
humidityPercent: 50, // Below min of 60
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const humidityAlert = alerts.find(a => a.parameter === 'humidity');
|
||||
expect(humidityAlert).toBeDefined();
|
||||
expect(humidityAlert?.type).toBe('low');
|
||||
});
|
||||
|
||||
it('should detect high humidity', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
humidityPercent: 90, // Above max of 80
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const humidityAlert = alerts.find(a => a.parameter === 'humidity');
|
||||
expect(humidityAlert).toBeDefined();
|
||||
expect(humidityAlert?.type).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EC Alerts', () => {
|
||||
it('should detect low EC', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
ec: 0.8, // Below min of 1.2
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const ecAlert = alerts.find(a => a.parameter === 'ec');
|
||||
expect(ecAlert).toBeDefined();
|
||||
expect(ecAlert?.type).toBe('low');
|
||||
});
|
||||
|
||||
it('should detect high EC', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
ec: 2.5, // Above max of 1.8
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const ecAlert = alerts.find(a => a.parameter === 'ec');
|
||||
expect(ecAlert).toBeDefined();
|
||||
expect(ecAlert?.type).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pH Alerts', () => {
|
||||
it('should detect low pH', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
ph: 5.2, // Below min of 5.8
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const phAlert = alerts.find(a => a.parameter === 'ph');
|
||||
expect(phAlert).toBeDefined();
|
||||
expect(phAlert?.type).toBe('low');
|
||||
});
|
||||
|
||||
it('should detect high pH', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
ph: 7.0, // Above max of 6.2
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const phAlert = alerts.find(a => a.parameter === 'ph');
|
||||
expect(phAlert).toBeDefined();
|
||||
expect(phAlert?.type).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Alerts', () => {
|
||||
it('should detect multiple issues simultaneously', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 30,
|
||||
humidityPercent: 40,
|
||||
ec: 0.5,
|
||||
ph: 7.5,
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
expect(alerts.length).toBeGreaterThanOrEqual(4);
|
||||
expect(alerts.some(a => a.parameter === 'temperature')).toBe(true);
|
||||
expect(alerts.some(a => a.parameter === 'humidity')).toBe(true);
|
||||
expect(alerts.some(a => a.parameter === 'ec')).toBe(true);
|
||||
expect(alerts.some(a => a.parameter === 'ph')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert Properties', () => {
|
||||
it('should include threshold value', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 15,
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
|
||||
expect(tempAlert?.threshold).toBeDefined();
|
||||
expect(tempAlert?.threshold).toBe(18); // Min threshold
|
||||
});
|
||||
|
||||
it('should include actual value', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 15,
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
|
||||
expect(tempAlert?.value).toBe(15);
|
||||
});
|
||||
|
||||
it('should include timestamp', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 15,
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
|
||||
expect(tempAlert?.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set acknowledged to false by default', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 15,
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('zone-001', readings);
|
||||
const tempAlert = alerts.find(a => a.parameter === 'temperature');
|
||||
|
||||
expect(tempAlert?.acknowledged).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zone Environment Updates', () => {
|
||||
it('should update zone current environment', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 22,
|
||||
humidityPercent: 65,
|
||||
});
|
||||
|
||||
controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const updatedFarm = controller.getFarm('farm-001');
|
||||
const zone = updatedFarm?.zones.find(z => z.id === 'zone-001');
|
||||
|
||||
expect(zone?.currentEnvironment.temperatureC).toBe(22);
|
||||
expect(zone?.currentEnvironment.humidityPercent).toBe(65);
|
||||
});
|
||||
|
||||
it('should store alerts in zone readings', () => {
|
||||
const farm = createVerticalFarm('farm-001');
|
||||
controller.registerFarm(farm);
|
||||
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 30, // Will trigger alert
|
||||
});
|
||||
|
||||
controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
const updatedFarm = controller.getFarm('farm-001');
|
||||
const zone = updatedFarm?.zones.find(z => z.id === 'zone-001');
|
||||
|
||||
expect(zone?.currentEnvironment.alerts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Health Score', () => {
|
||||
it('should decrease health score on alerts', () => {
|
||||
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 initialHealth = batch.healthScore;
|
||||
|
||||
// Record problematic environment
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 30, // High temp alert
|
||||
});
|
||||
|
||||
controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
// Health should decrease
|
||||
expect(batch.healthScore).toBeLessThan(initialHealth);
|
||||
});
|
||||
|
||||
it('should decrease health more for critical alerts', () => {
|
||||
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 initialHealth = batch.healthScore;
|
||||
|
||||
// Record critical environment issue
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 5, // Critical low temp
|
||||
});
|
||||
|
||||
controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
// Health should decrease significantly
|
||||
expect(batch.healthScore).toBeLessThan(initialHealth - 3);
|
||||
});
|
||||
|
||||
it('should not go below 0', () => {
|
||||
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
|
||||
);
|
||||
|
||||
// Record many critical issues
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 5,
|
||||
});
|
||||
controller.recordEnvironment('zone-001', readings);
|
||||
}
|
||||
|
||||
expect(batch.healthScore).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Logging', () => {
|
||||
it('should log environment readings to 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 readings: ZoneEnvironmentReadings = createReadings({});
|
||||
controller.recordEnvironment('zone-001', readings);
|
||||
|
||||
expect(batch.environmentLog.length).toBe(1);
|
||||
expect(batch.environmentLog[0].readings).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accumulate environment logs', () => {
|
||||
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
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 20 + i,
|
||||
});
|
||||
controller.recordEnvironment('zone-001', readings);
|
||||
}
|
||||
|
||||
expect(batch.environmentLog.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown Zone', () => {
|
||||
it('should return empty alerts for unknown zone', () => {
|
||||
const readings: ZoneEnvironmentReadings = createReadings({
|
||||
temperatureC: 5, // Would normally trigger alert
|
||||
});
|
||||
|
||||
const alerts = controller.recordEnvironment('unknown-zone', readings);
|
||||
expect(alerts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function createVerticalFarm(id: string): VerticalFarm {
|
||||
return {
|
||||
id,
|
||||
name: 'Test Farm',
|
||||
ownerId: 'owner-001',
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
address: '123 Test St',
|
||||
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: [],
|
||||
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 createReadings(overrides: Partial<ZoneEnvironmentReadings>): 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: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
453
__tests__/lib/vertical-farming/recipes.test.ts
Normal file
453
__tests__/lib/vertical-farming/recipes.test.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
/**
|
||||
* Recipe Stage Logic Tests
|
||||
* Tests for growing recipe and stage management
|
||||
*/
|
||||
|
||||
import {
|
||||
VerticalFarmController,
|
||||
} from '../../../lib/vertical-farming/controller';
|
||||
import {
|
||||
GrowingRecipe,
|
||||
GrowthStage,
|
||||
VerticalFarm,
|
||||
GrowingZone,
|
||||
} from '../../../lib/vertical-farming/types';
|
||||
|
||||
describe('Growing Recipes', () => {
|
||||
let controller: VerticalFarmController;
|
||||
|
||||
beforeEach(() => {
|
||||
controller = new VerticalFarmController();
|
||||
});
|
||||
|
||||
describe('Default Recipes', () => {
|
||||
it('should have complete stage definitions', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
expect(recipe.stages.length).toBeGreaterThan(0);
|
||||
expect(recipe.expectedDays).toBeGreaterThan(0);
|
||||
|
||||
// Stages should cover full duration
|
||||
const lastStage = recipe.stages[recipe.stages.length - 1];
|
||||
expect(lastStage.daysEnd).toBeLessThanOrEqual(recipe.expectedDays);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have non-overlapping stages', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
for (let i = 1; i < recipe.stages.length; i++) {
|
||||
const prevStage = recipe.stages[i - 1];
|
||||
const currentStage = recipe.stages[i];
|
||||
expect(currentStage.daysStart).toBeGreaterThan(prevStage.daysStart);
|
||||
expect(currentStage.daysStart).toBeGreaterThanOrEqual(prevStage.daysEnd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should have valid environment targets per stage', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
recipe.stages.forEach(stage => {
|
||||
// Temperature
|
||||
expect(stage.temperature.day).toBeGreaterThan(0);
|
||||
expect(stage.temperature.night).toBeGreaterThan(0);
|
||||
expect(stage.temperature.night).toBeLessThanOrEqual(stage.temperature.day);
|
||||
|
||||
// Humidity
|
||||
expect(stage.humidity.day).toBeGreaterThan(0);
|
||||
expect(stage.humidity.day).toBeLessThanOrEqual(100);
|
||||
expect(stage.humidity.night).toBeGreaterThan(0);
|
||||
expect(stage.humidity.night).toBeLessThanOrEqual(100);
|
||||
|
||||
// CO2
|
||||
expect(stage.co2Ppm).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Light
|
||||
expect(stage.lightHours).toBeGreaterThanOrEqual(0);
|
||||
expect(stage.lightHours).toBeLessThanOrEqual(24);
|
||||
expect(stage.lightPpfd).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Nutrients
|
||||
expect(stage.targetEc).toBeGreaterThanOrEqual(0);
|
||||
expect(stage.targetPh).toBeGreaterThan(0);
|
||||
expect(stage.targetPh).toBeLessThan(14);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lettuce Recipe', () => {
|
||||
it('should have correct growth stages', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const lettuce = recipes.find(r => r.cropType === 'lettuce')!;
|
||||
|
||||
expect(lettuce.stages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const stageNames = lettuce.stages.map(s => s.name);
|
||||
expect(stageNames).toContain('Germination');
|
||||
expect(stageNames).toContain('Seedling');
|
||||
});
|
||||
|
||||
it('should have approximately 35 day cycle', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const lettuce = recipes.find(r => r.cropType === 'lettuce')!;
|
||||
|
||||
expect(lettuce.expectedDays).toBeGreaterThanOrEqual(30);
|
||||
expect(lettuce.expectedDays).toBeLessThanOrEqual(45);
|
||||
});
|
||||
|
||||
it('should have transplant action', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const lettuce = recipes.find(r => r.cropType === 'lettuce')!;
|
||||
|
||||
const hasTransplant = lettuce.stages.some(stage =>
|
||||
stage.actions.some(action => action.action === 'transplant')
|
||||
);
|
||||
expect(hasTransplant).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basil Recipe', () => {
|
||||
it('should have higher temperature requirements', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const basil = recipes.find(r => r.cropType === 'basil')!;
|
||||
const lettuce = recipes.find(r => r.cropType === 'lettuce')!;
|
||||
|
||||
// Basil prefers warmer temperatures
|
||||
const basilMaxTemp = Math.max(...basil.stages.map(s => s.temperature.day));
|
||||
const lettuceMaxTemp = Math.max(...lettuce.stages.map(s => s.temperature.day));
|
||||
|
||||
expect(basilMaxTemp).toBeGreaterThanOrEqual(lettuceMaxTemp);
|
||||
});
|
||||
|
||||
it('should have longer cycle than microgreens', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const basil = recipes.find(r => r.cropType === 'basil')!;
|
||||
const microgreens = recipes.find(r => r.cropType === 'microgreens')!;
|
||||
|
||||
expect(basil.expectedDays).toBeGreaterThan(microgreens.expectedDays);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Microgreens Recipe', () => {
|
||||
it('should have short cycle', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const microgreens = recipes.find(r => r.cropType === 'microgreens')!;
|
||||
|
||||
expect(microgreens.expectedDays).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
it('should start in darkness (sowing stage)', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const microgreens = recipes.find(r => r.cropType === 'microgreens')!;
|
||||
|
||||
const sowingStage = microgreens.stages.find(s => s.name === 'Sowing');
|
||||
if (sowingStage) {
|
||||
expect(sowingStage.lightHours).toBe(0);
|
||||
expect(sowingStage.lightPpfd).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have high humidity in early stages', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
const microgreens = recipes.find(r => r.cropType === 'microgreens')!;
|
||||
|
||||
const earlyStage = microgreens.stages[0];
|
||||
expect(earlyStage.humidity.day).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stage Transitions', () => {
|
||||
it('should update environment targets on stage change', () => {
|
||||
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',
|
||||
100
|
||||
);
|
||||
|
||||
// Initial stage
|
||||
const initialStage = recipe.stages[0];
|
||||
const farmAfter = controller.getFarm('farm-001');
|
||||
const zone = farmAfter?.zones.find(z => z.id === 'zone-001');
|
||||
|
||||
expect(zone?.environmentTargets.temperatureC.target).toBe(initialStage.temperature.day);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recipe Requirements', () => {
|
||||
it('should specify zone type requirements', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
expect(recipe.requirements.zoneType).toBeDefined();
|
||||
expect(['NFT', 'DWC', 'ebb_flow', 'aeroponics', 'vertical_towers', 'rack_system'])
|
||||
.toContain(recipe.requirements.zoneType);
|
||||
});
|
||||
});
|
||||
|
||||
it('should specify minimum light requirements', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
expect(recipe.requirements.minimumPpfd).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should specify temperature requirements', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
expect(recipe.requirements.idealTemperatureC).toBeGreaterThan(0);
|
||||
expect(recipe.requirements.idealTemperatureC).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Yield Expectations', () => {
|
||||
it('should have reasonable yield estimates', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
expect(recipe.expectedYieldGrams).toBeGreaterThan(0);
|
||||
expect(recipe.expectedYieldPerSqm).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have consistent yield calculations', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
// Yield per sqm should be reasonable multiple of per-plant yield
|
||||
const plantsPerSqm = recipe.expectedYieldPerSqm / recipe.expectedYieldGrams;
|
||||
expect(plantsPerSqm).toBeGreaterThan(0);
|
||||
expect(plantsPerSqm).toBeLessThan(100); // Reasonable plant density
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stage Actions', () => {
|
||||
it('should have day specified for each action', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
recipe.stages.forEach(stage => {
|
||||
stage.actions.forEach(action => {
|
||||
expect(action.day).toBeGreaterThanOrEqual(stage.daysStart);
|
||||
expect(action.day).toBeLessThanOrEqual(stage.daysEnd);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have automation flag for actions', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
recipe.stages.forEach(stage => {
|
||||
stage.actions.forEach(action => {
|
||||
expect(typeof action.automated).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have descriptions for actions', () => {
|
||||
const recipes = controller.getRecipes();
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
recipe.stages.forEach(stage => {
|
||||
stage.actions.forEach(action => {
|
||||
expect(action.description).toBeDefined();
|
||||
expect(action.description.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Recipes', () => {
|
||||
it('should validate custom recipe structure', () => {
|
||||
const customRecipe: GrowingRecipe = {
|
||||
id: 'recipe-custom',
|
||||
name: 'Custom Recipe',
|
||||
cropType: 'cucumber',
|
||||
version: '1.0',
|
||||
stages: [
|
||||
{
|
||||
name: 'Stage 1',
|
||||
daysStart: 0,
|
||||
daysEnd: 30,
|
||||
temperature: { day: 25, night: 22 },
|
||||
humidity: { day: 70, night: 75 },
|
||||
co2Ppm: 1000,
|
||||
lightHours: 16,
|
||||
lightPpfd: 400,
|
||||
nutrientRecipeId: 'nutrient-veg',
|
||||
targetEc: 2.0,
|
||||
targetPh: 5.8,
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
expectedDays: 60,
|
||||
expectedYieldGrams: 500,
|
||||
expectedYieldPerSqm: 10000,
|
||||
requirements: {
|
||||
positions: 1,
|
||||
zoneType: 'NFT',
|
||||
minimumPpfd: 300,
|
||||
idealTemperatureC: 24,
|
||||
},
|
||||
source: 'internal',
|
||||
timesUsed: 0,
|
||||
};
|
||||
|
||||
controller.addRecipe(customRecipe);
|
||||
const recipes = controller.getRecipes();
|
||||
const found = recipes.find(r => r.id === 'recipe-custom');
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.cropType).toBe('cucumber');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function
|
||||
function createVerticalFarm(id: string): VerticalFarm {
|
||||
return {
|
||||
id,
|
||||
name: 'Test Farm',
|
||||
ownerId: 'owner-001',
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
address: '123 Test St',
|
||||
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: [],
|
||||
buildingType: 'warehouse',
|
||||
insulation: 'high_efficiency',
|
||||
},
|
||||
zones: [
|
||||
{
|
||||
id: 'zone-001',
|
||||
name: 'Zone A',
|
||||
level: 1,
|
||||
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',
|
||||
},
|
||||
],
|
||||
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,
|
||||
};
|
||||
}
|
||||
31
jest.config.js
Normal file
31
jest.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/__tests__'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', {
|
||||
tsconfig: 'tsconfig.json',
|
||||
}],
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'lib/**/*.ts',
|
||||
'!lib/**/*.d.ts',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
setupFilesAfterEnv: [],
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -9,6 +9,9 @@
|
|||
"start": "next start -p 3001",
|
||||
"preview": "bun run build && bun run start",
|
||||
"lint": "next lint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"cy:open": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
"test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open",
|
||||
|
|
@ -31,12 +34,15 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint-config-next": "^12.0.10",
|
||||
"jest": "^29.5.0",
|
||||
"postcss": "^8.4.5",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^4.5.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue