- 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
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
/**
|
|
* 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',
|
|
};
|
|
}
|