Implement EMBA Specialization Solver web app
Full React+TypeScript app with LP-based optimization engine, drag-and-drop specialization ranking (with touch/arrow support), course selection UI, results dashboard with decision tree, and two optimization modes (maximize-count, priority-order).
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maximizeCount, priorityOrder, determineStatuses, optimize } from '../optimizer';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
// A finance-heavy selection that should achieve finance specs
|
||||
const financeHeavyCourses = [
|
||||
'spr2-financial-services', // BNK CRF FIN FIM
|
||||
'spr3-mergers-acquisitions', // CRF FIN LCM STR(S1)
|
||||
'spr4-fintech', // FIN
|
||||
'spr5-corporate-finance', // CRF FIN
|
||||
'sum3-valuation', // BNK CRF FIN FIM
|
||||
'fall1-private-equity', // BNK CRF FIN FIM STR(S2)
|
||||
'fall2-behavioral-finance', // BNK CRF FIN FIM
|
||||
'fall3-climate-finance', // BNK CRF FIN FIM GLB SBI
|
||||
'fall4-financial-services', // BNK CRF FIN FIM
|
||||
];
|
||||
|
||||
describe('maximizeCount', () => {
|
||||
it('returns empty achieved when no courses are selected', () => {
|
||||
const result = maximizeCount([], allSpecIds, []);
|
||||
expect(result.achieved).toEqual([]);
|
||||
});
|
||||
|
||||
it('achieves specs with enough qualifying courses', () => {
|
||||
const result = maximizeCount(financeHeavyCourses, allSpecIds, []);
|
||||
expect(result.achieved.length).toBeGreaterThan(0);
|
||||
// Should be able to achieve some combo of FIN, CRF, BNK, FIM
|
||||
for (const specId of result.achieved) {
|
||||
expect(['BNK', 'CRF', 'FIN', 'FIM', 'LCM', 'GLB', 'SBI', 'STR']).toContain(specId);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers higher-priority specs when breaking ties', () => {
|
||||
// Put FIM first in ranking
|
||||
const ranking = ['FIM', ...allSpecIds.filter((id) => id !== 'FIM')];
|
||||
const result1 = maximizeCount(financeHeavyCourses, ranking, []);
|
||||
|
||||
// Put BNK first
|
||||
const ranking2 = ['BNK', ...allSpecIds.filter((id) => id !== 'BNK')];
|
||||
const result2 = maximizeCount(financeHeavyCourses, ranking2, []);
|
||||
|
||||
// Both should achieve the same count
|
||||
expect(result1.achieved.length).toBe(result2.achieved.length);
|
||||
|
||||
// If they achieve multiple, the first-ranked spec should appear when possible
|
||||
if (result1.achieved.length > 0) {
|
||||
// The highest-priority feasible spec should be in the result
|
||||
expect(result1.achieved).toContain('FIM');
|
||||
}
|
||||
});
|
||||
|
||||
it('never exceeds 3 specializations', () => {
|
||||
const result = maximizeCount(financeHeavyCourses, allSpecIds, []);
|
||||
expect(result.achieved.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('priorityOrder', () => {
|
||||
it('guarantees the top-ranked feasible spec', () => {
|
||||
const ranking = ['FIN', ...allSpecIds.filter((id) => id !== 'FIN')];
|
||||
const result = priorityOrder(financeHeavyCourses, ranking, []);
|
||||
expect(result.achieved[0]).toBe('FIN');
|
||||
});
|
||||
|
||||
it('skips infeasible specs and continues', () => {
|
||||
// GLB has very few qualifying courses in this set
|
||||
const ranking = ['GLB', 'FIN', 'CRF', ...allSpecIds.filter((id) => !['GLB', 'FIN', 'CRF'].includes(id))];
|
||||
const result = priorityOrder(financeHeavyCourses, ranking, []);
|
||||
// GLB might not be achievable on its own, but FIN should be
|
||||
expect(result.achieved).toContain('FIN');
|
||||
});
|
||||
|
||||
it('returns empty when no courses are selected', () => {
|
||||
const result = priorityOrder([], allSpecIds, []);
|
||||
expect(result.achieved).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineStatuses', () => {
|
||||
it('marks achieved specs correctly', () => {
|
||||
const statuses = determineStatuses(financeHeavyCourses, [], ['FIN', 'CRF']);
|
||||
expect(statuses['FIN']).toBe('achieved');
|
||||
expect(statuses['CRF']).toBe('achieved');
|
||||
});
|
||||
|
||||
it('marks missing_required when required course set is pinned to different course', () => {
|
||||
// spr4-fintech selected (not sustainability or entrepreneurship)
|
||||
const statuses = determineStatuses(['spr4-fintech'], [], []);
|
||||
expect(statuses['SBI']).toBe('missing_required');
|
||||
expect(statuses['ENT']).toBe('missing_required');
|
||||
});
|
||||
|
||||
it('marks achievable when required course is in open set', () => {
|
||||
const statuses = determineStatuses(
|
||||
[],
|
||||
['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'],
|
||||
[],
|
||||
);
|
||||
// All specs with enough potential should be achievable
|
||||
expect(statuses['FIN']).toBe('achievable');
|
||||
expect(statuses['MGT']).toBe('achievable');
|
||||
});
|
||||
|
||||
it('marks unreachable when upper bound is below threshold', () => {
|
||||
// Only 1 set open, most specs won't have enough potential
|
||||
const statuses = determineStatuses([], ['spr1'], []);
|
||||
// Most specs only have 1 qualifying course in spr1 (2.5 credits < 9)
|
||||
expect(statuses['FIN']).toBe('unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optimize (integration)', () => {
|
||||
it('returns a complete AllocationResult', () => {
|
||||
const result = optimize(financeHeavyCourses, allSpecIds, [], 'maximize-count');
|
||||
expect(result.achieved).toBeDefined();
|
||||
expect(result.allocations).toBeDefined();
|
||||
expect(result.statuses).toBeDefined();
|
||||
expect(result.upperBounds).toBeDefined();
|
||||
expect(Object.keys(result.statuses).length).toBe(14);
|
||||
});
|
||||
|
||||
it('modes can produce different results', () => {
|
||||
// Put a niche spec first in priority order
|
||||
const ranking = ['EMT', ...allSpecIds.filter((id) => id !== 'EMT')];
|
||||
const maxResult = optimize(financeHeavyCourses, ranking, [], 'maximize-count');
|
||||
const prioResult = optimize(financeHeavyCourses, ranking, [], 'priority-order');
|
||||
|
||||
// Both should produce valid results
|
||||
expect(maxResult.achieved.length).toBeGreaterThanOrEqual(0);
|
||||
expect(prioResult.achieved.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user