- 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
298 lines
9.8 KiB
TypeScript
298 lines
9.8 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|