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