mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
205 lines
8.0 KiB
TypeScript
205 lines
8.0 KiB
TypeScript
import {
|
||
CARGO_CATEGORIES,
|
||
CARGO_CATEGORY_EXPR,
|
||
CARGO_CATEGORY_LABEL_EXPR,
|
||
CONTAINER_CLASSES,
|
||
CONTAINER_CLASS_EXPR,
|
||
HANDLING_STANDARD_HOURS_EXPR,
|
||
TARGET_DIMENSION_KEYS,
|
||
cycleRateExpr,
|
||
handlingHours,
|
||
implementRateExpr,
|
||
loadingHours,
|
||
otherActivityHours,
|
||
plannedRowsSql,
|
||
} from './operations-classification';
|
||
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
|
||
|
||
/**
|
||
* Every key a classification CASE can emit, read straight off the expression.
|
||
* The categories are the join key between a report and its planned target, so a
|
||
* key the reports emit but the target dimension list does not offer is a plan
|
||
* nobody can ever enter.
|
||
*/
|
||
function emittedKeys(expr: string): string[] {
|
||
return [...expr.matchAll(/THEN '([A-Z_]+)'/g)]
|
||
.map(([, key]) => key)
|
||
.concat([...expr.matchAll(/ELSE '([A-Z_]+)'/g)].map(([, key]) => key));
|
||
}
|
||
|
||
describe('operations classification', () => {
|
||
/**
|
||
* The loading window falls back to the bookings that boarded at the stop, off
|
||
* the DEPARTING schedule — a turnaround loads for the leg it leaves on, not
|
||
* the one it arrived on. Losing either half of that silently turns a
|
||
* populated report back into an empty one.
|
||
*/
|
||
it('falls back to the booking-derived loading window, off the departing leg', () => {
|
||
for (const expr of [loadingHours('s'), handlingHours('s')]) {
|
||
expect(expr).toContain('COALESCE(s.loading_started_at');
|
||
expect(expr).toContain('s.departed_schedule_id');
|
||
expect(expr).toContain('b.origin_yard_id = (s.yard_id)');
|
||
}
|
||
});
|
||
|
||
/** Unloading is never derived — auto-unload would report it as ~0 hours. */
|
||
it('never derives the unloading half', () => {
|
||
expect(handlingHours('s')).toContain('s.unloading_started_at');
|
||
expect(handlingHours('s')).not.toContain('b.destination_yard_id');
|
||
});
|
||
|
||
/**
|
||
* Every other standard coalesces to the spec's figure. This one must not:
|
||
* the spec names no handling standard, and a fallback would publish a rate
|
||
* against a number nobody agreed to.
|
||
*/
|
||
it('leaves the handling standard null when nobody has set one', () => {
|
||
expect(HANDLING_STANDARD_HOURS_EXPR).toContain('std.handling_standard_hours_container');
|
||
expect(HANDLING_STANDARD_HOURS_EXPR).not.toContain('COALESCE(std.handling');
|
||
});
|
||
|
||
/** An unmeasured stop reports unknown activity, not a full stay of it. */
|
||
it('keeps other activity null when there is no handling window', () => {
|
||
expect(otherActivityHours('stay', 'handling')).toContain('IS NULL THEN NULL');
|
||
});
|
||
|
||
it('offers every cargo category the expression can emit as a filter option', () => {
|
||
const offered = new Set(CARGO_CATEGORIES.map((o) => o.value));
|
||
const missing = [...new Set(emittedKeys(CARGO_CATEGORY_EXPR))].filter((k) => !offered.has(k));
|
||
expect(missing).toEqual([]);
|
||
});
|
||
|
||
it('offers every container class the expression can emit', () => {
|
||
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
|
||
const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k));
|
||
expect(missing).toEqual([]);
|
||
});
|
||
|
||
it('labels every category, leaving none showing a raw key', () => {
|
||
for (const option of CARGO_CATEGORIES) {
|
||
expect(CARGO_CATEGORY_LABEL_EXPR).toContain(`'${option.label}'`);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* A planner types a dimension key into the targets screen; the reports match
|
||
* it against what their CASE emits. If the two lists ever drift, a target is
|
||
* silently ignored — the report shows no plan and nobody is told why.
|
||
*/
|
||
it('accepts every emitted key as a target dimension key', () => {
|
||
const emitted = [
|
||
...new Set([
|
||
...emittedKeys(CARGO_CATEGORY_EXPR),
|
||
...emittedKeys(CONTAINER_CLASS_EXPR),
|
||
]),
|
||
];
|
||
const unplannable = emitted.filter((k) => !TARGET_DIMENSION_KEYS.includes(k));
|
||
expect(unplannable).toEqual([]);
|
||
});
|
||
|
||
it('keeps the target metric and dimension vocabularies non-empty and distinct', () => {
|
||
expect(new Set(TARGET_METRICS).size).toBe(TARGET_METRICS.length);
|
||
expect(new Set(TARGET_DIMENSIONS).size).toBe(TARGET_DIMENSIONS.length);
|
||
});
|
||
|
||
/**
|
||
* The spec's worked example: a full trainset holds 50 wagons, 30 of them
|
||
* carry multimodal cargo, so that cargo operated 0.6 trainsets. The SQL does
|
||
* this division; this checks the arithmetic the SQL encodes.
|
||
*/
|
||
it('matches the spec worked example for trainsets', () => {
|
||
expect(Number((30 / 50).toFixed(2))).toBe(0.6);
|
||
});
|
||
|
||
/** Ten 40ft boxes and thirty 20ft boxes is fifty TEU, not forty. */
|
||
it('matches the spec worked example for TEU', () => {
|
||
expect(10 * 2 + 30 * 1).toBe(50);
|
||
});
|
||
|
||
it('divides by NULLIF so a missing plan yields no rate rather than infinity', () => {
|
||
expect(implementRateExpr('operated', 'planned')).toContain('NULLIF(planned, 0)');
|
||
});
|
||
|
||
/**
|
||
* [(SC − AD) / SC + 1] × 100 — finishing exactly on standard scores 100, and
|
||
* beating it scores above 100. Guards the sign, which is easy to invert.
|
||
*/
|
||
it('encodes the turnaround rate so on-standard is 100 and faster is more', () => {
|
||
const rate = (sc: number, ad: number) => ((sc - ad) / sc + 1) * 100;
|
||
expect(rate(65, 65)).toBe(100);
|
||
expect(rate(65, 52)).toBeGreaterThan(100);
|
||
expect(rate(65, 78)).toBeLessThan(100);
|
||
expect(cycleRateExpr('ad', 'sc')).toContain('NULLIF(sc, 0)');
|
||
});
|
||
|
||
/**
|
||
* The plan side is FULL OUTER JOINed to the operated side, so a plan row for
|
||
* a category the user filtered out comes back as a row of zeros — the bug
|
||
* where `?categories=FERTILIZER` still returned all ten planned categories.
|
||
*/
|
||
describe('plannedRowsSql cargo filter', () => {
|
||
const sqlFor = (dimension: string, params: Record<string, unknown>): string =>
|
||
plannedRowsSql('VOLUME_TONS', dimension, params, 'SELECT 1');
|
||
|
||
it('restricts targets to the selected categories', () => {
|
||
expect(sqlFor('cargo_category', { categories: ['FERTILIZER'] })).toContain(
|
||
"AND ot.dimension_key IN ('FERTILIZER')",
|
||
);
|
||
});
|
||
|
||
it('restricts a station target on its cargo type, not its key', () => {
|
||
const sql = sqlFor('station', { categories: ['SAND'] });
|
||
expect(sql).toContain("AND ot.cargo_category IN ('SAND')");
|
||
expect(sql).not.toContain('ot.dimension_key IN');
|
||
});
|
||
|
||
it('filters a container class report on its own vocabulary', () => {
|
||
expect(sqlFor('container_class', { classes: ['CONTAINER_EXPORT'] })).toContain(
|
||
"AND ot.dimension_key IN ('CONTAINER_EXPORT')",
|
||
);
|
||
});
|
||
|
||
it('leaves every target when nothing is selected', () => {
|
||
expect(sqlFor('cargo_category', {})).not.toContain('ot.dimension_key IN');
|
||
});
|
||
|
||
it('matches nothing on a value no category expression can emit', () => {
|
||
expect(sqlFor('cargo_category', { categories: ["x'; DROP TABLE"] })).toContain('AND FALSE');
|
||
});
|
||
|
||
it('narrows a station plan to the chosen country rather than suppressing it', () => {
|
||
const sql = sqlFor('station', { country: 'Djibouti' });
|
||
expect(sql).toContain("y.country = 'Djibouti'");
|
||
expect(sql).not.toContain('AND FALSE');
|
||
});
|
||
|
||
it('ignores a country that is not one of the two sides', () => {
|
||
expect(sqlFor('station', { country: "' OR true --" })).not.toContain('y.country');
|
||
});
|
||
|
||
it('leaves the country alone on a plan not keyed by station', () => {
|
||
expect(sqlFor('cargo_category', { country: 'Djibouti' })).not.toContain('y.country');
|
||
});
|
||
|
||
/**
|
||
* No target carries a route, a train or a direction, so beside a
|
||
* route-filtered actual the plan would be the whole corridor's target.
|
||
*/
|
||
it.each(['origin', 'destination', 'trainNumber', 'direction'])(
|
||
'reports no plan at all when %s narrows below the target grain',
|
||
(key) => {
|
||
expect(sqlFor('cargo_category', { [key]: 'X' })).toContain('AND FALSE');
|
||
},
|
||
);
|
||
|
||
it('keeps the plan when only period, date and category are set', () => {
|
||
const sql = sqlFor('cargo_category', {
|
||
period: 'month',
|
||
dateFrom: '2026-01-01',
|
||
categories: ['SAND'],
|
||
});
|
||
expect(sql).not.toContain('AND FALSE');
|
||
});
|
||
});
|
||
});
|