Merge pull request #6 from vespo92/claude/complete-agent-1-tasks-01L8WoAAyH71WNAYtfK3pbaY
Complete Agent 1 tasks from report
This commit is contained in:
commit
29f276dff0
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",
|
"start": "next start -p 3001",
|
||||||
"preview": "bun run build && bun run start",
|
"preview": "bun run build && bun run start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
"cy:open": "cypress open",
|
"cy:open": "cypress open",
|
||||||
"cy:run": "cypress run",
|
"cy:run": "cypress run",
|
||||||
"test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open",
|
"test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open",
|
||||||
|
|
@ -31,12 +34,15 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.12.9",
|
"@babel/core": "^7.12.9",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
"@types/node": "^17.0.21",
|
"@types/node": "^17.0.21",
|
||||||
"@types/react": "^17.0.0",
|
"@types/react": "^17.0.0",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
"eslint-config-next": "^12.0.10",
|
"eslint-config-next": "^12.0.10",
|
||||||
|
"jest": "^29.5.0",
|
||||||
"postcss": "^8.4.5",
|
"postcss": "^8.4.5",
|
||||||
"tailwindcss": "^3.0.15",
|
"tailwindcss": "^3.0.15",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
"typescript": "^4.5.5"
|
"typescript": "^4.5.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue