Add dashboard report snapshots feature with CRUD operations. Implemented new database migrations for dashboard_report_snapshots, including report type handling. Updated backend routes and models to support snapshot retrieval and upsert functionality. Enhanced frontend components to integrate report snapshot management, including UI for daily and end-cycle reports. Added tests for new functionality to ensure reliability.
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-08-13 15:46:16 +07:00
1 parent 059087be1d
commit 34336b9bd1
41 files changed
+4824 -178

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect } from 'vitest';
import type { FeedSackColumnConfig } from '../../types/counting.ts';
import { buildReportSnapshot } from '../buildReportSnapshot.ts';
const START = '2026-05-21T17:00:00.000Z'; // 22 Mei 2026 00:00 WIB
const COLUMNS: FeedSackColumnConfig[] = [
{
id: 'in-1',
label: 'Karung Masuk K1',
cameraName: 'kandang_1_karung_masuk',
color: '#22c55e',
type: 'inbound',
kandangId: 1,
},
{
id: 'use-1',
label: 'Dituang K1',
cameraName: 'kandang_1_karung_pakan',
color: '#eab308',
type: 'usage',
kandangId: 1,
},
{
id: 'use-2',
label: 'Dituang K2',
cameraName: 'kandang_2_karung_pakan',
color: '#a855f7',
type: 'usage',
kandangId: 2,
},
{
id: 'out-1',
label: 'Keluar',
cameraName: 'kandang_1_karung_keluar',
color: '#ef4444',
type: 'outbound',
kandangId: 1,
},
];
const baseInput = {
cycleId: 'CYCLE-TEST',
cycleStartDate: START,
kandangId: 1,
kandangName: 'Kandang 1',
area: 'Sukawarna',
upToDay: 2,
initialPopulation: 100,
generatedAt: '2026-05-24T10:00:00.000Z',
mortalityRecords: [],
chickenCountByDay: {} as Record<number, number>,
feedSackHistory: [] as Array<{ cameraName: string; count: number; timestamp: Date | string }>,
manualFeedEntries: [] as Array<{ cameraName: string; count: number; date: string }>,
feedColumnConfig: COLUMNS,
cageUuid: 'cage-1',
weightByDay: [] as Array<{
day: number;
avgManual?: number | null;
avgIot?: number | null;
uniformity?: number | null;
}>,
};
describe('buildReportSnapshot', () => {
it('decreases counting after-count by mortality+harvest only for days 0–2', () => {
const snapshot = buildReportSnapshot({
...baseInput,
mortalityRecords: [
{ day: 1, mortalityCount: 3, afkir: 2, panen: 1, beratPanen: 4.5 },
{ day: 2, mortalityCount: 1, afkir: 0, panen: 0 },
],
});
expect(snapshot.counting).toHaveLength(3);
expect(snapshot.counting[0]).toMatchObject({
day: 0,
countBefore: 100,
mortality: 0,
harvest: 0,
countAfter: 100,
});
expect(snapshot.counting[1]).toMatchObject({
day: 1,
countBefore: 100,
mortality: 5,
harvest: 1,
harvestWeight: 4.5,
countAfter: 94,
});
expect(snapshot.counting[2]).toMatchObject({
day: 2,
countBefore: 94,
mortality: 1,
harvest: 0,
countAfter: 93,
});
});
it('combines left and right usage cameras for IoT and manual', () => {
const snapshot = buildReportSnapshot({
...baseInput,
kandangName: 'Kandang Atas',
upToDay: 0,
feedColumnConfig: [
{
id: 'in-1',
label: 'Karung Masuk',
cameraName: 'kandang_1_karung_masuk',
color: '#22c55e',
type: 'inbound',
},
{
id: 'use-atas',
label: 'Karung Dituang Kandang Atas',
cameraName: 'karung_tuang_feeder_kiri_atas',
color: '#f97316',
type: 'usage',
},
{
id: 'use-bawah',
label: 'Karung Dituang Kandang Bawah',
cameraName: 'karung_tuang_feeder_kanan_bawah',
color: '#a855f7',
type: 'usage',
},
],
feedSackHistory: [
{
cameraName: 'karung_tuang_feeder_kiri_atas',
count: 15,
timestamp: new Date(2026, 4, 22, 8, 0, 0),
},
{
cameraName: 'karung_tuang_feeder_kanan_bawah',
count: 15,
timestamp: new Date(2026, 4, 22, 8, 0, 0),
},
],
manualFeedEntries: [
{ cameraName: 'karung_tuang_feeder_kiri_atas', count: 15, date: '2026-05-22' },
{ cameraName: 'karung_tuang_feeder_kanan_bawah', count: 15, date: '2026-05-22' },
],
});
expect(snapshot.feedUse).toHaveLength(1);
expect(snapshot.feedUse[0]?.iotUse).toBe(30);
expect(snapshot.feedUse[0]?.manualUse).toBe(30);
expect(snapshot.feedUse[0]?.accuracy).toBe(100);
});
it('keeps feed-in empty when inbound mapping fails, but still combines usage cameras', () => {
const snapshot = buildReportSnapshot({
...baseInput,
kandangId: 99,
upToDay: 0,
feedSackHistory: [
{
cameraName: 'kandang_1_karung_pakan',
count: 10,
timestamp: new Date(2026, 4, 22, 8, 0, 0),
},
{
cameraName: 'kandang_2_karung_pakan',
count: 50,
timestamp: new Date(2026, 4, 22, 8, 0, 0),
},
],
});
expect(snapshot.feedUse[0]?.iotUse).toBe(60);
expect(snapshot.feedUse[0]?.manualUse).toBe(0);
expect(snapshot.feedIn[0]?.iotIn).toBeNull();
expect(snapshot.feedIn[0]?.manualIn).toBeNull();
});
it('returns empty weight rows without cageUuid but still builds a snapshot', () => {
const snapshot = buildReportSnapshot({
...baseInput,
cageUuid: null,
weightByDay: [{ day: 1, avgManual: 1000, avgIot: 900, uniformity: 80 }],
});
expect(snapshot.cycleId).toBe('CYCLE-TEST');
expect(snapshot.weight.every((row) => row.avgManual == null && row.avgIot == null)).toBe(
true
);
});
it('drops rows after the selected day', () => {
const snapshot = buildReportSnapshot({
...baseInput,
upToDay: 8,
mortalityRecords: [
{ day: 8, mortalityCount: 1 },
{ day: 9, mortalityCount: 99 },
],
weightByDay: [
{ day: 9, avgManual: 2000, avgIot: 1900 }, // 1-based day 9 → report day 8
{ day: 10, avgManual: 3000, avgIot: 2900 }, // report day 9 — dropped
],
});
expect(snapshot.counting.every((row) => row.day <= 8)).toBe(true);
expect(snapshot.counting.some((row) => row.day === 9)).toBe(false);
expect(snapshot.weight.some((row) => row.day === 8 && row.avgManual === 2000)).toBe(true);
expect(snapshot.weight.some((row) => row.avgManual === 3000)).toBe(false);
});
it('maps 1-based feed day 1 on cycle start to Hari ke-0', () => {
const snapshot = buildReportSnapshot({
...baseInput,
upToDay: 0,
feedSackHistory: [
{
cameraName: 'kandang_1_karung_masuk',
count: 7,
timestamp: new Date(2026, 4, 22, 6, 0, 0),
},
],
manualFeedEntries: [{ cameraName: 'kandang_1_karung_masuk', count: 7, date: '2026-05-22' }],
});
expect(snapshot.feedIn).toHaveLength(1);
expect(snapshot.feedIn[0] && 'day' in snapshot.feedIn[0]).toBe(false);
expect(snapshot.feedIn[0]?.date).toBe('2026-05-22');
expect(snapshot.feedIn[0]?.iotIn).toBe(7);
expect(snapshot.feedIn[0]?.manualIn).toBe(7);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { choosePdfSliceHeight, pdfBlockBreakOffsets } from '../pdfExport.ts';
const mocks = vi.hoisted(() => ({
addImage: vi.fn(),
save: vi.fn(),
jsPDF: vi.fn(),
html2canvas: vi.fn(),
}));
vi.mock('jspdf', () => ({
jsPDF: mocks.jsPDF,
}));
vi.mock('html2canvas', () => ({
default: mocks.html2canvas,
}));
describe('exportCardToPdf compression', () => {
beforeEach(() => {
mocks.addImage.mockReset();
mocks.save.mockReset();
mocks.jsPDF.mockReset();
mocks.html2canvas.mockReset();
mocks.jsPDF.mockImplementation(function JsPDF() {
return {
internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } },
addPage: vi.fn(),
addImage: mocks.addImage,
save: mocks.save,
};
});
mocks.html2canvas.mockImplementation(async () => {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
return canvas;
});
});
it('embeds JPEG pages in a compressed jsPDF document', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
fillStyle: '',
fillRect: vi.fn(),
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D);
const toDataURL = vi
.spyOn(HTMLCanvasElement.prototype, 'toDataURL')
.mockReturnValue('data:image/jpeg;base64,xx');
const { exportCardToPdf } = await import('../pdfExport.ts');
await exportCardToPdf(document.createElement('div'), 'Laporan.pdf');
expect(mocks.jsPDF).toHaveBeenCalledWith(expect.objectContaining({ compress: true }));
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', expect.any(Number));
const quality = toDataURL.mock.calls.find((call) => call[0] === 'image/jpeg')?.[1] as number;
expect(quality).toBeGreaterThan(0);
expect(quality).toBeLessThan(1);
expect(mocks.addImage).toHaveBeenCalledWith(
expect.any(String),
'JPEG',
expect.any(Number),
expect.any(Number),
expect.any(Number),
expect.any(Number)
);
expect(mocks.save).toHaveBeenCalledWith('Laporan.pdf');
toDataURL.mockRestore();
vi.restoreAllMocks();
});
});
describe('pdf page breaks', () => {
it('does not defer an oversized table past the header', () => {
const usableHeight = 1200;
const breaks = pdfBlockBreakOffsets([{ top: 80, bottom: 5000 }], usableHeight);
expect(breaks).not.toContain(80);
expect(breaks).toContain(5000);
expect(choosePdfSliceHeight(0, 5000, usableHeight, breaks)).toBe(usableHeight);
});
it('keeps a small chart whole by breaking before it when it no longer fits', () => {
const usableHeight = 400;
const breaks = pdfBlockBreakOffsets([{ top: 350, bottom: 500 }], usableHeight);
expect(breaks).toEqual(expect.arrayContaining([350, 500]));
expect(choosePdfSliceHeight(0, 900, usableHeight, breaks)).toBe(350);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import {
countingAccuracy,
sackAccuracy,
weightAccuracy,
combinedMortality,
} from '../reportAccuracy.ts';
describe('countingAccuracy', () => {
it('uses smaller/larger × 100 for IoT vs after-count', () => {
expect(countingAccuracy(95, 100)).toBe(95);
});
it('returns no accuracy when after-count is 0', () => {
expect(countingAccuracy(95, 0)).toBeNull();
});
it('returns no accuracy when IoT count is missing', () => {
expect(countingAccuracy(null, 100)).toBeNull();
expect(countingAccuracy(undefined, 100)).toBeNull();
});
});
describe('sackAccuracy', () => {
it('is 100% when both sides are 0', () => {
expect(sackAccuracy(0, 0)).toBe(100);
});
it('is N/A when only IoT is set', () => {
expect(sackAccuracy(null, 10)).toBeNull();
expect(sackAccuracy(0, 10)).toBeNull();
});
it('is 100% when both sides are 10', () => {
expect(sackAccuracy(10, 10)).toBe(100);
});
});
describe('weightAccuracy', () => {
it('uses smaller/larger × 100 for average IoT vs average manual', () => {
expect(weightAccuracy(1000, 900)).toBe(90);
});
});
describe('combinedMortality', () => {
it('sums mati + afkir and ignores panen', () => {
expect(combinedMortality(3, 2, 10)).toBe(5);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { closedCycleUpToDay, isCycleClosed } from '../reportCycle.ts';
describe('isCycleClosed', () => {
it('is false when endDate is missing', () => {
expect(isCycleClosed(undefined)).toBe(false);
expect(isCycleClosed(null)).toBe(false);
expect(isCycleClosed('')).toBe(false);
});
it('is true when endDate is set', () => {
expect(isCycleClosed('2026-06-25')).toBe(true);
expect(isCycleClosed(new Date('2026-06-25T00:00:00'))).toBe(true);
});
});
describe('closedCycleUpToDay', () => {
it('returns the inclusive report day of endDate', () => {
expect(closedCycleUpToDay('2026-05-22', '2026-05-30')).toBe(8);
});
it('returns 0 when endDate is missing or before start', () => {
expect(closedCycleUpToDay('2026-05-22', null)).toBe(0);
expect(closedCycleUpToDay('2026-05-22', '2026-05-20')).toBe(0);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import {
reportDayToDate,
dateToReportDay,
feedOrWeightDayToReportDay,
formatLocalDate,
} from '../reportDay.ts';
/** Same instant PdfReportHeader tests use: 22 Mei 2026 00:00 WIB. */
const START = '2026-05-21T17:00:00.000Z';
describe('reportDayToDate', () => {
it('maps day 0 to the cycle start date', () => {
const date = reportDayToDate(START, 0);
expect(formatLocalDate(date)).toBe('2026-05-22');
});
it('maps day 1 to the day after cycle start', () => {
const date = reportDayToDate(START, 1);
expect(formatLocalDate(date)).toBe('2026-05-23');
});
});
describe('dateToReportDay', () => {
it('maps cycle start calendar date to report day 0', () => {
expect(dateToReportDay(START, reportDayToDate(START, 0))).toBe(0);
});
});
describe('feedOrWeightDayToReportDay', () => {
it('maps 1-based feed day 1 on cycle start to report day 0', () => {
expect(feedOrWeightDayToReportDay(1)).toBe(0);
const start = reportDayToDate(START, 0);
expect(dateToReportDay(START, start)).toBe(0);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest';
import type { DashboardReportPayload } from '../../types/dashboardReport.ts';
import { buildReportWorkbook } from '../reportExcel.ts';
const snapshot: DashboardReportPayload = {
cycleId: 'CYCLE-JBW',
kandangId: 1,
kandangName: 'Kandang Atas',
area: 'Sukawarna',
upToDay: 2,
generatedAt: '2026-05-24T10:00:00.000Z',
counting: [
{
date: '2026-05-22',
day: 0,
countBefore: 100,
mortality: 0,
harvest: 0,
harvestWeight: null,
countAfter: 100,
iotCount: 95,
accuracy: 95,
},
],
weight: [
{
date: '2026-05-23',
day: 1,
avgManual: 1000,
avgIot: 900,
uniformity: 80,
difference: 100,
accuracy: 90,
},
],
feedIn: [{ date: '2026-05-22', manualIn: 10, iotIn: 10, accuracy: 100 }],
feedUse: [{ date: '2026-05-22', day: 0, manualUse: 4, iotUse: 4, accuracy: 100 }],
};
const emptySnapshot: DashboardReportPayload = {
...snapshot,
counting: [],
weight: [],
feedIn: [],
feedUse: [],
};
describe('buildReportWorkbook', () => {
it('creates four sheets with the specified names', async () => {
const workbook = await buildReportWorkbook(snapshot);
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([
'Counting',
'Weight',
'Feed In',
'Feed Use',
]);
});
it('includes chicken age and mortality on Counting, and no chicken-age on Feed In', async () => {
const workbook = await buildReportWorkbook(snapshot);
const countingHeaders = workbook.getWorksheet('Counting')?.getRow(2).values ?? [];
const feedInHeaders = workbook.getWorksheet('Feed In')?.getRow(2).values ?? [];
expect(countingHeaders).toEqual(
expect.arrayContaining(['Umur (hari)', 'Mortalitas'])
);
expect(feedInHeaders).toEqual(expect.arrayContaining(['Tanggal', 'Manual masuk', 'IoT masuk']));
expect(feedInHeaders).not.toEqual(expect.arrayContaining(['Umur (hari)']));
});
it('writes header metadata matching upToDay and generatedAt', async () => {
const workbook = await buildReportWorkbook(snapshot);
const headerValues = workbook.getWorksheet('Counting')?.getRow(1).values ?? [];
const header = (Array.isArray(headerValues) ? headerValues : [])
.map((value) => (value == null ? '' : String(value)))
.filter(Boolean)
.join(' ');
expect(header).toContain('Kandang Atas');
expect(header).toContain('CYCLE-JBW');
expect(header).toContain('Hari ke-0 s/d ke-2');
expect(header).toContain('2026-05-24T10:00:00.000Z');
});
it('still produces a workbook when snapshot tables are empty (AE5)', async () => {
const workbook = await buildReportWorkbook(emptySnapshot);
expect(workbook.worksheets).toHaveLength(4);
expect(workbook.getWorksheet('Counting')?.rowCount).toBeGreaterThanOrEqual(2);
});
it('appends a Total row on each sheet', async () => {
const workbook = await buildReportWorkbook(snapshot);
const lastValues = (sheetName: string) => {
const sheet = workbook.getWorksheet(sheetName);
const values = sheet?.getRow(sheet.rowCount).values;
return (Array.isArray(values) ? values : []).filter((value) => value != null);
};
expect(lastValues('Counting')[0]).toBe('Total');
expect(lastValues('Weight')[0]).toBe('2026-05-23');
expect(lastValues('Feed In')).toEqual(['Total', 10, 10, '100.0%']);
expect(lastValues('Feed Use')).toEqual(['Total', '—', 4, 4, '100.0%']);
});
it('adds FCR and EEF sheets for an end-cycle snapshot', async () => {
const workbook = await buildReportWorkbook({
...snapshot,
reportType: 'end_cycle',
fcr: [
{
day: 0,
iotFcr: 1.234,
feedTotal: 10,
iotWeight: 45.5,
chickenLife: 10000,
harvestWeightTotal: 12000,
},
],
eef: [
{
day: 0,
iotEef: 310.456,
chickenLifePercentage: 99.5,
iotAverageHarvestBw: 45.5,
iotAverageDayWeight: 0.2,
iotFcr: 1.234,
},
],
});
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([
'Counting',
'Weight',
'Feed In',
'Feed Use',
'FCR',
'EEF',
]);
const headerCells = (sheetName: string) => {
const values = workbook.getWorksheet(sheetName)?.getRow(2).values ?? [];
return (Array.isArray(values) ? values : []).filter((value) => value != null);
};
expect(headerCells('FCR')).toEqual([
'Umur',
'Total Pakan',
'Bobot IoT',
'Ayam Hidup',
'Total Bobot Panen',
'FCR',
]);
expect(headerCells('EEF')).toEqual([
'Umur',
'Persentase Ayam Hidup',
'BW Rata-rata Panen',
'Umur Rata-rata Panen',
'FCR',
'EEF',
]);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { latestValidRatio, mapKpiToEefRows, mapKpiToFcrRows } from '../reportFcrEef.ts';
const kpi = {
'0': {
food_total: 10,
iot_weight: 45.5,
chicken_life: 10000,
harvest_weight_total: 12000,
iot_fcr: 1.234,
chicken_life_percentage: 99.5,
iot_average_harvest_bw: 45.5,
iot_average_day_weight: 0.2,
iot_eef: 310.456,
},
'2': {
food_total: 20,
iot_weight: 80,
chicken_life: 9900,
harvest_weight_total: 50000,
iot_fcr: 0,
chicken_life_percentage: 98.1,
iot_average_harvest_bw: 80,
iot_average_day_weight: 2,
iot_eef: 0,
},
'8': {
food_total: 40,
iot_weight: 400,
chicken_life: 9700,
harvest_weight_total: 200000,
iot_fcr: 1.7,
chicken_life_percentage: 96.2,
iot_average_harvest_bw: 400,
iot_average_day_weight: 8,
iot_eef: 280.9,
},
};
describe('mapKpiToFcrRows', () => {
it('maps page FCR fields and keeps only days 0..upToDay', () => {
expect(mapKpiToFcrRows(kpi, 2)).toEqual([
{
day: 0,
feedTotal: 10,
iotWeight: 45.5,
chickenLife: 10000,
harvestWeightTotal: 12000,
iotFcr: 1.234,
},
{
day: 2,
feedTotal: 20,
iotWeight: 80,
chickenLife: 9900,
harvestWeightTotal: 50000,
iotFcr: null,
},
]);
});
it('returns an empty array for invalid KPI without throwing', () => {
expect(mapKpiToFcrRows(null, 8)).toEqual([]);
expect(mapKpiToFcrRows(undefined, 8)).toEqual([]);
expect(mapKpiToFcrRows('nope', 8)).toEqual([]);
});
});
describe('mapKpiToEefRows', () => {
it('maps page EEF fields and keeps only days 0..upToDay', () => {
expect(mapKpiToEefRows(kpi, 2)).toEqual([
{
day: 0,
chickenLifePercentage: 99.5,
iotAverageHarvestBw: 45.5,
iotAverageDayWeight: 0.2,
iotFcr: 1.234,
iotEef: 310.456,
},
{
day: 2,
chickenLifePercentage: 98.1,
iotAverageHarvestBw: 80,
iotAverageDayWeight: 2,
iotFcr: null,
iotEef: null,
},
]);
});
it('returns an empty array for invalid KPI without throwing', () => {
expect(mapKpiToEefRows(null, 8)).toEqual([]);
expect(mapKpiToEefRows(undefined, 8)).toEqual([]);
});
});
describe('latestValidRatio', () => {
it('returns the last non-null ratio, skipping empty later days', () => {
expect(
latestValidRatio(
[
{ day: 0, iotFcr: 1.234 },
{ day: 2, iotFcr: null },
{ day: 8, iotFcr: 1.7 },
],
'iotFcr'
)
).toBe(1.7);
expect(
latestValidRatio(
[
{ day: 0, iotEef: 310.456 },
{ day: 2, iotEef: null },
],
'iotEef'
)
).toBe(310.456);
});
it('returns null when every value is missing', () => {
expect(latestValidRatio([{ iotFcr: null }, { iotFcr: 0 }], 'iotFcr')).toBeNull();
expect(latestValidRatio([], 'iotFcr')).toBeNull();
expect(latestValidRatio(undefined, 'iotFcr')).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { generateButtonLabel, reportCtaLabel, reportOverlayTitle } from '../reportOverlayLabels.ts';
describe('report overlay labels', () => {
it('uses Laporan when no snapshot and Lihat Laporan when one exists', () => {
expect(reportCtaLabel(false)).toBe('Laporan');
expect(reportCtaLabel(true)).toBe('Lihat Laporan');
});
it('uses Generate then Generate ulang inside the preview', () => {
expect(generateButtonLabel(false)).toBe('Generate');
expect(generateButtonLabel(true)).toBe('Generate ulang');
});
it('titles daily vs end-cycle overlays', () => {
expect(reportOverlayTitle('daily')).toBe('Laporan Harian IoT vs Manual');
expect(reportOverlayTitle('end_cycle')).toBe('Laporan Akhir Siklus');
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import type {
DashboardReportCountingRow,
DashboardReportFeedInRow,
DashboardReportFeedUseRow,
} from '../../types/dashboardReport.ts';
import { countingTotals, feedInTotals, feedUseTotals } from '../reportTotals.ts';
describe('countingTotals', () => {
it('sums mortality, harvest, and harvest weight; omits stok akhir and IoT', () => {
const rows: DashboardReportCountingRow[] = [
{
date: '2026-05-22',
day: 0,
countBefore: 100,
mortality: 0,
harvest: 0,
harvestWeight: null,
countAfter: 100,
iotCount: 98,
accuracy: 98,
},
{
date: '2026-05-23',
day: 1,
countBefore: 100,
mortality: 5,
harvest: 1,
harvestWeight: 4.5,
countAfter: 94,
iotCount: 90,
accuracy: 95.7,
},
{
date: '2026-05-24',
day: 2,
countBefore: 94,
mortality: 2,
harvest: 0,
harvestWeight: null,
countAfter: 92,
iotCount: 91,
accuracy: 98.9,
},
];
expect(countingTotals(rows)).toEqual({
label: 'Total',
countBefore: 100,
mortality: 7,
harvest: 1,
harvestWeight: 4.5,
countAfter: null,
iotCount: null,
accuracy: null,
});
});
it('returns null when there are no counting rows', () => {
expect(countingTotals([])).toBeNull();
});
});
describe('feedInTotals', () => {
it('sums sacks and recomputes accuracy from the totals', () => {
const rows: DashboardReportFeedInRow[] = [
{ date: '2026-05-22', manualIn: 10, iotIn: 10, accuracy: 100 },
{ date: '2026-05-23', manualIn: 5, iotIn: 4, accuracy: 80 },
];
expect(feedInTotals(rows)).toEqual({
label: 'Total',
manualIn: 15,
iotIn: 14,
accuracy: 93.3,
});
});
});
describe('feedUseTotals', () => {
it('sums left+right daily sacks across the period', () => {
const rows: DashboardReportFeedUseRow[] = [
{ date: '2026-05-22', day: 0, manualUse: 30, iotUse: 30, accuracy: 100 },
{ date: '2026-05-23', day: 1, manualUse: 10, iotUse: 8, accuracy: 80 },
];
expect(feedUseTotals(rows)).toEqual({
label: 'Total',
manualUse: 40,
iotUse: 38,
accuracy: 95,
});
});
});