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
CI / lint-and-test (push) Canceled after 0s
This commit is contained in:
1 parent
059087be1d
commit
34336b9bd1
41 files changed
+4824
-178
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user