Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/constants/toolUtilization.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const UTILIZATION_THRESHOLDS = {
UNDER_UTILIZED_MAX: 55,
NORMAL_MAX: 85,
};

const UTILIZATION_LABELS = {
UNDER: 'Under-utilized',
NORMAL: 'Normal',
OVER: 'Over-utilized',
};

const TRAFFIC_LIGHT = {
GREEN: 'green',
YELLOW: 'yellow',
RED: 'red',
};

const FORECAST_MODES = {
HISTORICAL: 'historical',
FORECAST_30: 'forecast30',
FORECAST_FULL: 'forecastFull',
};

const VALID_FORECAST_MODES = Object.values(FORECAST_MODES);

const REPORT_FORMATS = ['pdf', 'csv'];

const MAINTENANCE_TRIGGER_THRESHOLD = 85;

const MINIMUM_WEEKS_FOR_REGRESSION = 3;

const FORECAST_DEFAULT_DAYS = 30;

const HOURS_PER_DAY = 24;
const DAYS_PER_WEEK = 7;
const MS_PER_HOUR = 3600000;

const DEGRADED_CONDITIONS = ['Worn', 'Needs Repair', 'Needs Replacing'];
const NON_OPERATIONAL_STATUSES = ['Under Maintenance', 'Out of Service'];

const CONFIDENCE_THRESHOLDS = {
HIGH: 0.7,
MEDIUM: 0.4,
};

const ENSEMBLE_WEIGHTS = {
HIGH_R2: { regression: 0.6, ema: 0.4 },
MEDIUM_R2: { regression: 0.4, ema: 0.6 },
LOW_R2: { regression: 0.2, ema: 0.8 },
};

const EMA_SMOOTHING_BASE = 2;

const ROUNDING_PRECISION = 10;
const PDF_MOVE_DOWN_HALF = 0.5;

const PDF_STYLES = {
FONT_SIZE_TITLE: 24,
FONT_SIZE_METADATA: 10,
FONT_SIZE_SECTION_HEADER: 16,
FONT_SIZE_BODY: 11,
FONT_SIZE_DETAIL: 10,
PAGE_BREAK_THRESHOLD: 700,
PAGE_MARGIN: 50,
};

module.exports = {
UTILIZATION_THRESHOLDS,
UTILIZATION_LABELS,
TRAFFIC_LIGHT,
FORECAST_MODES,
VALID_FORECAST_MODES,
REPORT_FORMATS,
MAINTENANCE_TRIGGER_THRESHOLD,
MINIMUM_WEEKS_FOR_REGRESSION,
FORECAST_DEFAULT_DAYS,
HOURS_PER_DAY,
DAYS_PER_WEEK,
MS_PER_HOUR,
DEGRADED_CONDITIONS,
NON_OPERATIONAL_STATUSES,
CONFIDENCE_THRESHOLDS,
ENSEMBLE_WEIGHTS,
EMA_SMOOTHING_BASE,
ROUNDING_PRECISION,
PDF_MOVE_DOWN_HALF,
PDF_STYLES,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
jest.mock('../../../helpers/toolUtilizationHelpers');
jest.mock('../../../helpers/toolUtilizationReportHelpers');
jest.mock('../../../startup/logger', () => ({ logException: jest.fn() }));

const {
computeUtilizationData,
buildUtilizationResponse,
generateRecommendations,
generateMaintenanceAlerts,
generateResourceBalancingSuggestions,
buildInsightsSummary,
buildReportPayload,
} = require('../../../helpers/toolUtilizationHelpers');
const {
generatePDFReport,
generateCSVReport,
} = require('../../../helpers/toolUtilizationReportHelpers');
const toolUtilizationController = require('../toolUtilizationController');

// ─── Shared setup ───
const mockBuildingTool = {};
const controller = toolUtilizationController(mockBuildingTool);

const makeReq = (query = {}) => ({ query });
const makeRes = () => ({
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
send: jest.fn(),
headersSent: false,
});

const mockRangeStart = new Date('2026-01-01');
const mockRangeEnd = new Date('2026-01-31');
const mockUtilizationData = [
{
name: 'Drill',
utilizationRate: 75,
downtime: 180,
classification: { label: 'Normal', trafficLight: 'green' },
toolCount: 1,
toolGroupDetails: { tools: [], purchaseStatuses: [], conditions: [], currentUsages: [] },
},
];
const mockReportPayload = {
utilizationData: mockUtilizationData,
alerts: [],
balancing: [],
recommendations: [],
summary: {},
metadata: {},
};

beforeEach(() => {
jest.clearAllMocks();
computeUtilizationData.mockResolvedValue({
utilizationData: mockUtilizationData,
rangeStart: mockRangeStart,
rangeEnd: mockRangeEnd,
});
buildUtilizationResponse.mockResolvedValue(mockUtilizationData);
generateRecommendations.mockReturnValue([]);
generateMaintenanceAlerts.mockReturnValue([]);
generateResourceBalancingSuggestions.mockReturnValue([]);
buildInsightsSummary.mockReturnValue({
totalToolTypes: 1,
underUtilized: 0,
normal: 1,
overUtilized: 0,
averageUtilization: 75,
});
buildReportPayload.mockReturnValue(mockReportPayload);
});

// ─── getUtilization ───
describe('getUtilization', () => {
it('returns 200 with utilization data when no mode is specified', async () => {
const req = makeReq({});
const res = makeRes();

await controller.getUtilization(req, res);

expect(computeUtilizationData).toHaveBeenCalledWith(mockBuildingTool, {
tool: undefined,
project: undefined,
startDate: undefined,
endDate: undefined,
});
expect(buildUtilizationResponse).toHaveBeenCalledWith(
expect.objectContaining({ selectedMode: 'historical' }),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(mockUtilizationData);
});

it('passes forecast30 mode to buildUtilizationResponse', async () => {
const req = makeReq({ mode: 'forecast30' });
const res = makeRes();

await controller.getUtilization(req, res);

expect(buildUtilizationResponse).toHaveBeenCalledWith(
expect.objectContaining({ selectedMode: 'forecast30' }),
);
expect(res.status).toHaveBeenCalledWith(200);
});

it('returns 400 when mode is invalid', async () => {
const req = makeReq({ mode: 'badMode' });
const res = makeRes();

await controller.getUtilization(req, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('Invalid mode') }),
);
});

it('returns 400 when startDate is after endDate', async () => {
const req = makeReq({ startDate: '2026-01-31', endDate: '2026-01-01' });
const res = makeRes();

await controller.getUtilization(req, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('startDate') }),
);
expect(computeUtilizationData).not.toHaveBeenCalled();
});

it('returns 500 when computeUtilizationData throws', async () => {
computeUtilizationData.mockRejectedValue(new Error('DB failure'));
const req = makeReq({});
const res = makeRes();

await controller.getUtilization(req, res);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('DB failure') }),
);
});

it('returns 500 when buildUtilizationResponse throws', async () => {
buildUtilizationResponse.mockRejectedValue(new Error('Response build failed'));
const req = makeReq({});
const res = makeRes();

await controller.getUtilization(req, res);

expect(res.status).toHaveBeenCalledWith(500);
});
});

// ─── getInsights ───
describe('getInsights', () => {
it('returns 200 with all insight sections', async () => {
const recommendations = [{ toolName: 'Drill', action: 'Plan maintenance.' }];
const alerts = [{ toolName: 'Drill', alertType: 'overuse', message: 'High.', urgency: 'high' }];
const balancing = [{ suggestion: 'Redistribute.', fromTool: 'Drill', toTool: 'Shovel' }];
const summary = {
totalToolTypes: 1,
underUtilized: 0,
normal: 0,
overUtilized: 1,
averageUtilization: 90,
};

generateRecommendations.mockReturnValue(recommendations);
generateMaintenanceAlerts.mockReturnValue(alerts);
generateResourceBalancingSuggestions.mockReturnValue(balancing);
buildInsightsSummary.mockReturnValue(summary);

const req = makeReq({});
const res = makeRes();

await controller.getInsights(req, res);

expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
recommendations,
maintenanceAlerts: alerts,
resourceBalancing: balancing,
summary,
});
});

it('calls all insight helpers with utilizationData from computeUtilizationData', async () => {
const req = makeReq({});
const res = makeRes();

await controller.getInsights(req, res);

expect(generateRecommendations).toHaveBeenCalledWith(mockUtilizationData);
expect(generateMaintenanceAlerts).toHaveBeenCalledWith(mockUtilizationData);
expect(generateResourceBalancingSuggestions).toHaveBeenCalledWith(mockUtilizationData);
expect(buildInsightsSummary).toHaveBeenCalledWith(mockUtilizationData);
});

it('returns 400 when startDate is after endDate', async () => {
const req = makeReq({ startDate: '2026-01-31', endDate: '2026-01-01' });
const res = makeRes();

await controller.getInsights(req, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('startDate') }),
);
expect(computeUtilizationData).not.toHaveBeenCalled();
});

it('returns 500 when computeUtilizationData throws', async () => {
computeUtilizationData.mockRejectedValue(new Error('DB error'));
const req = makeReq({});
const res = makeRes();

await controller.getInsights(req, res);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('DB error') }),
);
});
});

// ─── exportReport ───
describe('exportReport', () => {
it('returns 400 when format param is missing', async () => {
const req = makeReq({});
const res = makeRes();

await controller.exportReport(req, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('Invalid format') }),
);
});

it('returns 400 when format is not pdf or csv', async () => {
const req = makeReq({ format: 'xlsx' });
const res = makeRes();

await controller.exportReport(req, res);

expect(res.status).toHaveBeenCalledWith(400);
});

it('returns 400 when startDate is after endDate', async () => {
const req = makeReq({ format: 'csv', startDate: '2026-01-31', endDate: '2026-01-01' });
const res = makeRes();

await controller.exportReport(req, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('startDate') }),
);
expect(computeUtilizationData).not.toHaveBeenCalled();
});

it('calls generateCSVReport for csv format', async () => {
const req = makeReq({ format: 'csv' });
const res = makeRes();

await controller.exportReport(req, res);

expect(generateCSVReport).toHaveBeenCalledWith(res, mockReportPayload);
expect(generatePDFReport).not.toHaveBeenCalled();
});

it('calls generatePDFReport for pdf format', async () => {
const req = makeReq({ format: 'pdf' });
const res = makeRes();

await controller.exportReport(req, res);

expect(generatePDFReport).toHaveBeenCalledWith(res, mockReportPayload);
expect(generateCSVReport).not.toHaveBeenCalled();
});

it('returns 500 when error occurs and headers are not yet sent', async () => {
computeUtilizationData.mockRejectedValue(new Error('Export failed'));
const req = makeReq({ format: 'csv' });
const res = { ...makeRes(), headersSent: false };

await controller.exportReport(req, res);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('Export failed') }),
);
});

it('does not send error response when headers are already sent', async () => {
computeUtilizationData.mockRejectedValue(new Error('Stream error'));
const req = makeReq({ format: 'csv' });
const res = { ...makeRes(), headersSent: true };

await controller.exportReport(req, res);

expect(res.status).not.toHaveBeenCalled();
});
});
Loading
Loading