Files
dashboard/utils/__tests__/feedSackClassification.test.ts
T

125 lines
4.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import type { FeedSackColumnConfig } from '../../types/counting.ts';
/**
* The classification the insight context now uses: camera -> type comes from
* the saved column configuration, and a camera absent from it is not feed-sack
* data at all. Mirrors `cameraTypeByName` / `sumFeedRows` in
* FeedSackCountingPage.
*/
const classify = (
logs: Array<{ cameraName: string; count: number }>,
config: FeedSackColumnConfig[]
) => {
const byName = new Map(config.map((c) => [c.cameraName, c.type]));
const totals = { masuk: 0, dituang: 0, keluar: 0, diabaikan: 0 };
for (const log of logs) {
const type = byName.get(log.cameraName);
if (type === 'inbound') totals.masuk += log.count;
else if (type === 'usage') totals.dituang += log.count;
else if (type === 'outbound') totals.keluar += log.count;
else totals.diabaikan += log.count;
}
return totals;
};
// Exactly what feed_sack_column_config holds in the database.
const CONFIG: FeedSackColumnConfig[] = [
{
id: 'a',
label: 'Karung Masuk',
cameraName: 'kandang_1_karung_masuk',
color: '#22c55e',
type: 'inbound',
},
{
id: 'b',
label: 'Karung Dituang Kandang Atas',
cameraName: 'karung_tuang_feeder_kiri_atas',
color: '#f97316',
type: 'usage',
},
{
id: 'c',
label: 'Karung Dituang Kandang Bawah',
cameraName: 'karung_tuang_feeder_kanan_bawah',
color: '#a855f7',
type: 'usage',
},
{
id: 'd',
label: 'Karung Keluar',
cameraName: 'kandang_1_karung_masuk_out',
color: '#3b82f6',
type: 'outbound',
},
];
// Real totals from the live API for cycle days 43-49 (3-9 Jul 2026).
const WEEK_43_49 = [
{ cameraName: 'karung_tuang_feeder_kanan_bawah', count: 11 },
{ cameraName: 'karung_tuang_feeder_kiri_atas', count: 10 },
{ cameraName: 'kandang_1_karung_masuk_out', count: 0 },
{ cameraName: 'kandang_1_karung_masuk', count: 14 },
{ cameraName: 'kandang_atas_feeder_kiri', count: 1057 },
{ cameraName: 'kandang_bawah_feeder_kanan', count: 1127 },
];
describe('feed sack classification', () => {
it('counts only configured cameras', () => {
const t = classify(WEEK_43_49, CONFIG);
expect(t.dituang).toBe(21); // was 2205 when unconfigured cameras leaked in
expect(t.masuk).toBe(14);
expect(t.keluar).toBe(0);
expect(t.diabaikan).toBe(2184);
});
it('gives a believable daily average', () => {
const t = classify(WEEK_43_49, CONFIG);
expect(Math.round(t.dituang / 7)).toBe(3); // was 315 karung/hari
});
it('files kandang_1_karung_masuk_out as outbound, not inbound', () => {
// The old substring rule matched "karung_masuk" inside "..._out".
const t = classify([{ cameraName: 'kandang_1_karung_masuk_out', count: 387 }], CONFIG);
expect(t.keluar).toBe(387);
expect(t.masuk).toBe(0);
});
it('ignores a camera that is not feed-sack data', () => {
const t = classify([{ cameraName: 'kandang_atas_feeder_kiri', count: 7695 }], CONFIG);
expect(t.dituang).toBe(0);
expect(t.diabaikan).toBe(7695);
});
});
/** Manual entries for the same cameras, days 43-49 (3-9 Jul 2026). */
const MANUAL_43_49 = [
{ cameraName: 'kandang_1_karung_masuk', count: 8 }, // 3 Jul
{ cameraName: 'kandang_1_karung_masuk', count: 6 }, // 6 Jul
{ cameraName: 'kandang_1_karung_masuk', count: 2 }, // 7 Jul
];
describe('manual figures shown beside the camera figures', () => {
it('totals manual separately from IOT', () => {
const iot = classify(WEEK_43_49, CONFIG);
const manual = classify(MANUAL_43_49, CONFIG);
expect(iot.masuk).toBe(14);
expect(manual.masuk).toBe(16);
});
it('never merges the two into one number', () => {
// Both count the same sacks; 30 would be double-counting.
const iot = classify(WEEK_43_49, CONFIG);
const manual = classify(MANUAL_43_49, CONFIG);
expect(iot.masuk + manual.masuk).toBe(30);
expect(iot.masuk).not.toBe(30);
expect(manual.masuk).not.toBe(30);
});
it('reports zero manual for a camera with no entries in the window', () => {
const manual = classify(MANUAL_43_49, CONFIG);
expect(manual.dituang).toBe(0); // manual "dituang" entries stop at 2 Jul
});
});