- Set up Jest testing framework with TypeScript support - Add unit tests for TransportChain blockchain (tracker, carbon, types) - Add unit tests for DemandForecaster (forecaster, aggregation, recommendations) - Add unit tests for VerticalFarmController (controller, recipes, environment) - Add API tests for transport, demand, and vertical-farm endpoints - Add integration tests for full lifecycle workflows: - Seed-to-seed lifecycle - Demand-to-harvest flow - VF batch lifecycle
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|