3a5ebaa17a
Students can now record credits earned in courses taken outside the J27 program via an inline editable amber chip on each spec card. Values flow through the LP (per-spec demand reduces by external amount), upper-bound math, decision-tree search, and the credit bar visualization. The 9-credit threshold and the 3-spec achievement cap are unchanged; required-course gates remain authoritative — external credits never satisfy them.
246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
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');
|
|
});
|
|
|
|
it('marks spec as unreachable when infeasible alongside achieved specs due to credit sharing', () => {
|
|
// Bug scenario: CRF+STR achieved, LCM has 10 credit upper bound but
|
|
// shared courses (spr3, fall3) are consumed by CRF/STR
|
|
const selectedCourses = [
|
|
'spr1-collaboration', // LCM, MGT
|
|
'spr2-financial-services', // BNK, CRF, FIN, FIM
|
|
'spr3-mergers-acquisitions', // CRF, FIN, LCM, STR(S1)
|
|
'spr4-foundations-entrepreneurship', // ENT, MGT, STR(S1)
|
|
'spr5-corporate-finance', // CRF, FIN
|
|
'sum1-global-immersion', // GLB
|
|
'sum2-business-drivers', // STR(S1)
|
|
'sum3-valuation', // BNK, CRF, FIN, FIM
|
|
'fall1-managing-change', // LCM, MGT, STR(S2)
|
|
'fall2-decision-models', // MGT, MTO
|
|
'fall3-corporate-governance', // LCM, MGT, SBI, STR(S1)
|
|
'fall4-game-theory', // MGT, STR(S1)
|
|
];
|
|
|
|
// Baseline: LCM is achievable when no specs are achieved (upper bound alone)
|
|
const statusesBaseline = determineStatuses(selectedCourses, [], []);
|
|
expect(statusesBaseline['LCM']).toBe('achievable');
|
|
|
|
// Core bug scenario: CRF+STR achieved (without MGT), LCM should still be unreachable
|
|
const statusesWithoutMgt = determineStatuses(selectedCourses, [], ['CRF', 'STR']);
|
|
expect(statusesWithoutMgt['LCM']).toBe('unreachable');
|
|
|
|
// LCM upper bound is 10 (>= 9) but infeasible alongside CRF+STR+MGT
|
|
const statusesWithMgt = determineStatuses(selectedCourses, [], ['CRF', 'STR', 'MGT']);
|
|
expect(statusesWithMgt['LCM']).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(SPECIALIZATIONS.length);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('externalCredits behavior', () => {
|
|
it('hard 3-spec cap holds even with external credits', () => {
|
|
// Even with 9 external HCR (a spec the courses don't otherwise support),
|
|
// maximizeCount must never report more than 3 achieved.
|
|
const result = maximizeCount(
|
|
financeHeavyCourses,
|
|
allSpecIds,
|
|
[],
|
|
undefined,
|
|
{ HCR: 9 },
|
|
);
|
|
expect(result.achieved.length).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
it('external credits can substitute into the 3-spec set', () => {
|
|
// External 9 in HCR makes HCR feasible for free; the optimizer can pick
|
|
// HCR as one of the 3 achieved specs.
|
|
const result = maximizeCount(
|
|
financeHeavyCourses,
|
|
allSpecIds,
|
|
[],
|
|
undefined,
|
|
{ HCR: 9 },
|
|
);
|
|
expect(result.achieved).toContain('HCR');
|
|
});
|
|
|
|
it('missing_required precedence: external alone cannot achieve a gated spec', () => {
|
|
// BRM requires fall4-brand-strategy. Don't include it; pin fall4-game-theory instead.
|
|
const noBrandStrategy = financeHeavyCourses.filter((c) => c !== 'fall4-financial-services')
|
|
.concat(['fall4-game-theory']);
|
|
|
|
const result = maximizeCount(
|
|
noBrandStrategy,
|
|
allSpecIds,
|
|
[],
|
|
undefined,
|
|
{ BRM: 9 },
|
|
);
|
|
expect(result.achieved).not.toContain('BRM');
|
|
|
|
const statuses = determineStatuses(
|
|
noBrandStrategy,
|
|
[],
|
|
result.achieved,
|
|
undefined,
|
|
{ BRM: 9 },
|
|
);
|
|
expect(statuses['BRM']).toBe('missing_required');
|
|
});
|
|
|
|
it('priorityOrder honors external credits in achievability', () => {
|
|
// Rank HCR first; without external it would be skipped, with 9 external it's first achieved
|
|
const ranking = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
|
const without = priorityOrder(financeHeavyCourses, ranking, []);
|
|
expect(without.achieved).not.toContain('HCR');
|
|
|
|
const withExt = priorityOrder(
|
|
financeHeavyCourses,
|
|
ranking,
|
|
[],
|
|
undefined,
|
|
{ HCR: 9 },
|
|
);
|
|
expect(withExt.achieved[0]).toBe('HCR');
|
|
});
|
|
|
|
it('upper bounds reflect external credit additions', () => {
|
|
const result = optimize(
|
|
[],
|
|
allSpecIds,
|
|
[],
|
|
'maximize-count',
|
|
undefined,
|
|
{ GLB: 5 },
|
|
);
|
|
expect(result.upperBounds['GLB']).toBe(5);
|
|
});
|
|
});
|