first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildInMemoryReport } from '../buildInMemoryReport.ts';
|
||||
import type { ChickenCounting, Cycle, Karung, KPI } from '../../types/api.ts';
|
||||
|
||||
const cycle: Cycle = {
|
||||
id: 1,
|
||||
kandang: 9,
|
||||
kandang_name: 'Kandang 1',
|
||||
total_days: 35,
|
||||
current_day: 1,
|
||||
visible_through_date: '2026-05-02',
|
||||
start_date: '2026-05-01',
|
||||
end_date: '2026-06-04',
|
||||
chick_in_weight: 45,
|
||||
doc_in_count: 20000,
|
||||
feed_initial_balance: 0,
|
||||
status: 'gm',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
|
||||
describe('buildInMemoryReport', () => {
|
||||
it('maps IoT counting and karung onto daily rows up to current_day', () => {
|
||||
const countings: ChickenCounting[] = [
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 9,
|
||||
flock_id: 1,
|
||||
date: '2026-05-02',
|
||||
total_count: 19800,
|
||||
mortality_count: 12,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
const karungs: Karung[] = [
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 9,
|
||||
flock_id: 1,
|
||||
date: '2026-05-02',
|
||||
in_today: 10,
|
||||
out_today: 8,
|
||||
in_total: 10,
|
||||
out_total: 8,
|
||||
feed_use_today: 8,
|
||||
feed_use_total: 8,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
const kpis: KPI[] = [
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 9,
|
||||
flock_id: 1,
|
||||
date: '2026-05-02',
|
||||
age: 1,
|
||||
mortality: 12,
|
||||
mortality_total: 12,
|
||||
feed: 8,
|
||||
feed_total: 8,
|
||||
harvest: 0,
|
||||
harvest_total: 0,
|
||||
harvest_weight: 0,
|
||||
harvest_weight_total: 0,
|
||||
chicken_life: 19800,
|
||||
chicken_life_percentage: 99,
|
||||
iot_weight: 0,
|
||||
average_harvest_day: 0,
|
||||
average_harvest_weight: 0,
|
||||
fcr: 1.2,
|
||||
eef: 250,
|
||||
cc: 1,
|
||||
cw: 1,
|
||||
fs: 1,
|
||||
manual: 1,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildInMemoryReport({
|
||||
cycle,
|
||||
kandangId: 9,
|
||||
kandangName: 'Kandang 1',
|
||||
area: 'Sukawarna',
|
||||
reportType: 'daily',
|
||||
countings,
|
||||
manuals: [],
|
||||
weights: [],
|
||||
karungs,
|
||||
kpis,
|
||||
});
|
||||
|
||||
expect(payload.upToDay).toBe(1);
|
||||
expect(payload.docWeight).toBe(45);
|
||||
expect(payload.counting).toHaveLength(2);
|
||||
const day0 = payload.counting.find((row) => row.day === 0);
|
||||
expect(day0?.countBefore).toBe(20000);
|
||||
expect(day0?.countAfter).toBe(20000);
|
||||
const day1 = payload.counting.find((row) => row.day === 1);
|
||||
expect(day1?.countBefore).toBe(20000);
|
||||
expect(day1?.iotCount).toBe(19800);
|
||||
expect(day1?.mortality).toBe(12);
|
||||
const feed1 = payload.feedUse.find((row) => row.day === 1);
|
||||
expect(feed1?.iotUse).toBe(8);
|
||||
expect(payload.fcr.find((row) => row.day === 1)?.iotFcr).toBe(1.2);
|
||||
});
|
||||
|
||||
it('uses doc_in_count for day-0 stok awal instead of reverse-calculating from stok akhir', () => {
|
||||
const countings: ChickenCounting[] = [
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 9,
|
||||
flock_id: 1,
|
||||
date: '2026-05-01',
|
||||
total_count: 36900,
|
||||
mortality_count: 42,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
const kpis: KPI[] = [
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 9,
|
||||
flock_id: 1,
|
||||
date: '2026-05-01',
|
||||
age: 0,
|
||||
mortality: 42,
|
||||
mortality_total: 42,
|
||||
feed: 0,
|
||||
feed_total: 0,
|
||||
harvest: 0,
|
||||
harvest_total: 0,
|
||||
harvest_weight: 0,
|
||||
harvest_weight_total: 0,
|
||||
chicken_life: 45000,
|
||||
chicken_life_percentage: 100,
|
||||
iot_weight: 0,
|
||||
average_harvest_day: 0,
|
||||
average_harvest_weight: 0,
|
||||
fcr: 0,
|
||||
eef: 0,
|
||||
cc: 1,
|
||||
cw: 1,
|
||||
fs: 1,
|
||||
manual: 1,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildInMemoryReport({
|
||||
cycle: { ...cycle, doc_in_count: 45000, current_day: 0, start_date: '2026-05-01' },
|
||||
kandangId: 9,
|
||||
kandangName: 'Kandang 1',
|
||||
area: 'Sukawarna',
|
||||
reportType: 'daily',
|
||||
countings,
|
||||
manuals: [],
|
||||
weights: [],
|
||||
karungs: [],
|
||||
kpis,
|
||||
});
|
||||
|
||||
const day0 = payload.counting.find((row) => row.day === 0);
|
||||
expect(day0?.countBefore).toBe(45000);
|
||||
expect(day0?.mortality).toBe(42);
|
||||
expect(day0?.countAfter).toBe(45000);
|
||||
expect(day0?.iotCount).toBe(36900);
|
||||
expect(day0?.accuracy).toBe(82);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../../types/api.ts';
|
||||
import { getCountingAccuracyWarning } from '../countingAccuracyWarning.ts';
|
||||
|
||||
const cycle = {
|
||||
id: 1,
|
||||
kandang: 1,
|
||||
kandang_name: 'K1',
|
||||
total_days: 35,
|
||||
current_day: 5,
|
||||
visible_through_date: '2026-08-15',
|
||||
start_date: '2026-08-10',
|
||||
end_date: '2026-09-13',
|
||||
chick_in_weight: 45,
|
||||
doc_in_count: 1000,
|
||||
feed_initial_balance: 0,
|
||||
status: 'gm',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
} satisfies Cycle;
|
||||
|
||||
function kpi(partial: Partial<KPI> & Pick<KPI, 'date' | 'chicken_life'>): KPI {
|
||||
return {
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
age: 0,
|
||||
mortality: 0,
|
||||
mortality_total: 0,
|
||||
feed: 0,
|
||||
feed_total: 0,
|
||||
harvest: 0,
|
||||
harvest_total: 0,
|
||||
harvest_weight: 0,
|
||||
harvest_weight_total: 0,
|
||||
chicken_life_percentage: 100,
|
||||
iot_weight: 0,
|
||||
average_harvest_day: 0,
|
||||
average_harvest_weight: 0,
|
||||
fcr: 0,
|
||||
eef: 0,
|
||||
cc: 0,
|
||||
cw: 0,
|
||||
fs: 0,
|
||||
manual: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function counting(partial: Partial<ChickenCounting> & Pick<ChickenCounting, 'date' | 'total_count'>): ChickenCounting {
|
||||
return {
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
mortality_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getCountingAccuracyWarning', () => {
|
||||
it('returns null when there is no IoT counting', () => {
|
||||
expect(
|
||||
getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns under when latest IoT is below stock and accuracy <= 95', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 930 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
day: 0,
|
||||
date: '2026-08-10',
|
||||
iotCount: 930,
|
||||
stockAkhir: 1000,
|
||||
direction: 'under',
|
||||
});
|
||||
expect(result!.accuracy).toBe(93);
|
||||
expect(result!.ratioPercent).toBe(93);
|
||||
});
|
||||
|
||||
it('returns over when latest IoT is above stock and accuracy <= 95', () => {
|
||||
// 1060/1000 → ratio 106%, accuracy min/max = 1000/1060 ≈ 94.3%
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 1060 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
day: 0,
|
||||
iotCount: 1060,
|
||||
stockAkhir: 1000,
|
||||
direction: 'over',
|
||||
});
|
||||
expect(result!.accuracy).toBe(94.3);
|
||||
expect(result!.ratioPercent).toBe(106);
|
||||
});
|
||||
|
||||
it('warns when accuracy is exactly 95', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 950 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({ accuracy: 95, direction: 'under' });
|
||||
});
|
||||
|
||||
it('returns null when accuracy is above 95', () => {
|
||||
expect(
|
||||
getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 960 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the latest IoT day even if an older day is worse', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [
|
||||
counting({ id: 1, date: '2026-08-10', total_count: 800 }), // worse under
|
||||
counting({ id: 2, date: '2026-08-12', total_count: 970 }), // latest, accuracy 97% → no warn
|
||||
],
|
||||
kpis: [
|
||||
kpi({ date: '2026-08-10', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-11', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-12', chicken_life: 1000 }),
|
||||
],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('warns from the latest IoT day when that day is low', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [
|
||||
counting({ id: 1, date: '2026-08-10', total_count: 990 }), // fine
|
||||
counting({ id: 2, date: '2026-08-12', total_count: 900 }), // latest under
|
||||
],
|
||||
kpis: [
|
||||
kpi({ date: '2026-08-10', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-11', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-12', chicken_life: 1000 }),
|
||||
],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
day: 2,
|
||||
date: '2026-08-12',
|
||||
iotCount: 900,
|
||||
direction: 'under',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives stock akhir from chain when chicken_life is missing', () => {
|
||||
const manuals: ManualInput[] = [
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-08-10',
|
||||
age_manual: 0,
|
||||
mortality_manual: 50,
|
||||
mortality_manual_total: 50,
|
||||
feed_in_manual: 0,
|
||||
feed_use_manual: 0,
|
||||
feed_in_manual_total: 0,
|
||||
feed_use_manual_total: 0,
|
||||
feed_out_manual: 0,
|
||||
feed_out_manual_total: 0,
|
||||
harvest_manual: 0,
|
||||
harvest_manual_total: 0,
|
||||
harvest_weight_manual: 0,
|
||||
harvest_weight_manual_total: 0,
|
||||
manual_weight: 0,
|
||||
average_harvest_day_manual: 0,
|
||||
average_harvest_weight_manual: 0,
|
||||
fcr_manual: 0,
|
||||
eef_manual: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 900 })],
|
||||
kpis: [],
|
||||
manualInputs: manuals,
|
||||
});
|
||||
// stock = 1000 - 50 = 950; IoT 900 → under, accuracy 900/950 ≈ 94.7
|
||||
expect(result).toMatchObject({
|
||||
stockAkhir: 950,
|
||||
iotCount: 900,
|
||||
direction: 'under',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { KPI } from '../../types/api.ts';
|
||||
import {
|
||||
isEndDateInWindow,
|
||||
latestValidRatios,
|
||||
rankingWindowStart,
|
||||
} from '../kandangRanking.ts';
|
||||
|
||||
function kpi(partial: Partial<KPI>): KPI {
|
||||
return {
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-01-01',
|
||||
age: 1,
|
||||
mortality: 0,
|
||||
mortality_total: 0,
|
||||
feed: 0,
|
||||
feed_total: 0,
|
||||
harvest: 0,
|
||||
harvest_total: 0,
|
||||
harvest_weight: 0,
|
||||
harvest_weight_total: 0,
|
||||
chicken_life: 1000,
|
||||
chicken_life_percentage: 100,
|
||||
iot_weight: 0,
|
||||
average_harvest_day: 0,
|
||||
average_harvest_weight: 0,
|
||||
fcr: 0,
|
||||
eef: 0,
|
||||
cc: 0,
|
||||
cw: 0,
|
||||
fs: 0,
|
||||
manual: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('kandangRanking helpers', () => {
|
||||
it('rankingWindowStart subtracts calendar months', () => {
|
||||
expect(rankingWindowStart(3, new Date(2026, 8, 8))).toBe('2026-06-08');
|
||||
expect(rankingWindowStart(1, new Date(2026, 0, 31))).toBe('2025-12-31');
|
||||
});
|
||||
|
||||
it('isEndDateInWindow is inclusive on the lower bound', () => {
|
||||
expect(isEndDateInWindow('2026-06-08', '2026-06-08')).toBe(true);
|
||||
expect(isEndDateInWindow('2026-06-07', '2026-06-08')).toBe(false);
|
||||
expect(isEndDateInWindow(null, '2026-06-08')).toBe(false);
|
||||
});
|
||||
|
||||
it('latestValidRatios takes last positive EEF by age, not array order', () => {
|
||||
// API-style newest-first ordering
|
||||
const rows = [
|
||||
kpi({ date: '2026-01-03', age: 3, eef: 310, fcr: 1.25 }),
|
||||
kpi({ date: '2026-01-02', age: 2, eef: 0, fcr: 1.3 }),
|
||||
kpi({ date: '2026-01-01', age: 1, eef: 200, fcr: 1.4 }),
|
||||
];
|
||||
expect(latestValidRatios(rows)).toEqual({ eef: 310, fcr: 1.25 });
|
||||
});
|
||||
|
||||
it('latestValidRatios finds FCR and EEF independently from latest days', () => {
|
||||
const rows = [
|
||||
kpi({ date: '2026-01-03', age: 3, eef: 320, fcr: 0 }),
|
||||
kpi({ date: '2026-01-02', age: 2, eef: 0, fcr: 1.22 }),
|
||||
kpi({ date: '2026-01-01', age: 1, eef: 100, fcr: 1.5 }),
|
||||
];
|
||||
expect(latestValidRatios(rows)).toEqual({ eef: 320, fcr: 1.22 });
|
||||
});
|
||||
|
||||
it('latestValidRatios returns null EEF when none valid', () => {
|
||||
const rows = [kpi({ age: 2, eef: 0, fcr: 1.2 }), kpi({ age: 1, eef: 0, fcr: 0 })];
|
||||
expect(latestValidRatios(rows)).toEqual({ eef: null, fcr: 1.2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildReportSummary, latestValidRatio } from '../reportSummary.ts';
|
||||
import type { ReportSummaryMetric, ReportSummaryMetricKey } from '../reportSummary.ts';
|
||||
import type { DashboardReportPayload } from '../../types/dashboardReport.ts';
|
||||
|
||||
function emptyPayload(overrides: Partial<DashboardReportPayload> = {}): DashboardReportPayload {
|
||||
return {
|
||||
cycleId: '1',
|
||||
cycleStart: '2026-05-01',
|
||||
cycleEnd: '2026-06-04',
|
||||
kandangId: 9,
|
||||
kandangName: 'Kandang 1',
|
||||
area: 'Area A',
|
||||
upToDay: 2,
|
||||
docWeight: null,
|
||||
generatedAt: '2026-05-03T00:00:00.000Z',
|
||||
reportType: 'daily',
|
||||
counting: [],
|
||||
weight: [],
|
||||
feedIn: [],
|
||||
feedUse: [],
|
||||
feedOut: [],
|
||||
fcr: [],
|
||||
eef: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('latestValidRatio', () => {
|
||||
it('returns the last finite value walking backward', () => {
|
||||
expect(
|
||||
latestValidRatio(
|
||||
[
|
||||
{ iotFcr: 1.1 },
|
||||
{ iotFcr: null },
|
||||
{ iotFcr: 1.4 },
|
||||
{ iotFcr: null },
|
||||
],
|
||||
'iotFcr'
|
||||
)
|
||||
).toBe(1.4);
|
||||
});
|
||||
|
||||
it('returns null when no finite values exist', () => {
|
||||
expect(latestValidRatio([{ iotEef: null }, { iotEef: null }], 'iotEef')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReportSummary', () => {
|
||||
it('returns 14 metrics in the agreed order with nulls when sections are empty', () => {
|
||||
const summary = buildReportSummary(emptyPayload({ upToDay: 5 }));
|
||||
expect(summary.metrics.map((m) => m.key)).toEqual([
|
||||
'umur',
|
||||
'docWeight',
|
||||
'populasi',
|
||||
'mortalitas',
|
||||
'panen',
|
||||
'beratPanen',
|
||||
'karungMasuk',
|
||||
'karungDituang',
|
||||
'beratPakan',
|
||||
'karungKeluar',
|
||||
'beratAvg',
|
||||
'uniformity',
|
||||
'fcr',
|
||||
'eef',
|
||||
]);
|
||||
expect(summary.metrics[0]).toMatchObject({ label: 'Umur', value: 5, unit: 'hari' });
|
||||
expect(summary.metrics.slice(1).every((m) => m.value === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('derives totals, latest populasi/weight, and FCR/EEF from payload rows', () => {
|
||||
const summary = buildReportSummary(
|
||||
emptyPayload({
|
||||
upToDay: 2,
|
||||
counting: [
|
||||
{
|
||||
date: '2026-05-01',
|
||||
day: 0,
|
||||
countBefore: 20000,
|
||||
mortality: 10,
|
||||
harvest: 0,
|
||||
harvestWeight: 0,
|
||||
countAfter: 19990,
|
||||
iotCount: null,
|
||||
accuracy: null,
|
||||
},
|
||||
{
|
||||
date: '2026-05-03',
|
||||
day: 2,
|
||||
countBefore: 19980,
|
||||
mortality: 5,
|
||||
harvest: 100,
|
||||
harvestWeight: 150000,
|
||||
countAfter: 19875,
|
||||
iotCount: 19870,
|
||||
accuracy: 99.9,
|
||||
},
|
||||
],
|
||||
weight: [
|
||||
{
|
||||
date: '2026-05-01',
|
||||
day: 0,
|
||||
avgManual: 45,
|
||||
avgIot: 46,
|
||||
uniformity: 80,
|
||||
difference: 1,
|
||||
accuracy: 97,
|
||||
},
|
||||
{
|
||||
date: '2026-05-03',
|
||||
day: 2,
|
||||
avgManual: 900,
|
||||
avgIot: 910,
|
||||
uniformity: 88.5,
|
||||
difference: 10,
|
||||
accuracy: 98,
|
||||
},
|
||||
],
|
||||
feedIn: [
|
||||
{ date: '2026-05-01', manualIn: 20, iotIn: 21, accuracy: 95 },
|
||||
{ date: '2026-05-03', manualIn: 10, iotIn: 10, accuracy: 100 },
|
||||
],
|
||||
feedUse: [
|
||||
{ date: '2026-05-01', day: 0, manualUse: 8, iotUse: 8, accuracy: 100 },
|
||||
{ date: '2026-05-03', day: 2, manualUse: 12, iotUse: 11, accuracy: 91 },
|
||||
],
|
||||
feedOut: [
|
||||
{ date: '2026-05-01', manualOut: 1, iotOut: 1, accuracy: 100 },
|
||||
{ date: '2026-05-03', manualOut: 2, iotOut: 2, accuracy: 100 },
|
||||
],
|
||||
fcr: [
|
||||
{
|
||||
day: 0,
|
||||
iotFcr: 0.5,
|
||||
feedTotal: 1,
|
||||
iotWeight: 46,
|
||||
chickenLife: 19990,
|
||||
harvestWeightTotal: 0,
|
||||
},
|
||||
{
|
||||
day: 2,
|
||||
iotFcr: 1.25,
|
||||
feedTotal: 20,
|
||||
iotWeight: 910,
|
||||
chickenLife: 19875,
|
||||
harvestWeightTotal: 150,
|
||||
},
|
||||
],
|
||||
eef: [
|
||||
{
|
||||
day: 0,
|
||||
iotEef: 100,
|
||||
chickenLifePercentage: 99.9,
|
||||
iotAverageHarvestBw: null,
|
||||
iotAverageDayWeight: 46,
|
||||
iotFcr: 0.5,
|
||||
},
|
||||
{
|
||||
day: 2,
|
||||
iotEef: 320,
|
||||
chickenLifePercentage: 99.4,
|
||||
iotAverageHarvestBw: 1500,
|
||||
iotAverageDayWeight: 910,
|
||||
iotFcr: 1.25,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const byKey = Object.fromEntries(summary.metrics.map((m) => [m.key, m])) as Record<
|
||||
ReportSummaryMetricKey,
|
||||
ReportSummaryMetric
|
||||
>;
|
||||
expect(byKey.umur.value).toBe(2);
|
||||
expect(byKey.docWeight.value).toBeNull();
|
||||
expect(byKey.populasi.value).toBe(19875);
|
||||
expect(byKey.mortalitas.value).toBe(15);
|
||||
expect(byKey.panen.value).toBe(100);
|
||||
expect(byKey.beratPanen.value).toBe(150);
|
||||
expect(byKey.karungMasuk.value).toBe(30);
|
||||
expect(byKey.karungDituang.value).toBe(20);
|
||||
expect(byKey.beratPakan.value).toBe(1000);
|
||||
expect(byKey.karungKeluar.value).toBe(3);
|
||||
expect(byKey.beratAvg.value).toBe(910);
|
||||
expect(byKey.uniformity.value).toBe(88.5);
|
||||
expect(byKey.fcr.value).toBe(1.25);
|
||||
expect(byKey.eef.value).toBe(320);
|
||||
});
|
||||
|
||||
it('falls back to avgManual when avgIot is null on the latest weight row', () => {
|
||||
const summary = buildReportSummary(
|
||||
emptyPayload({
|
||||
weight: [
|
||||
{
|
||||
date: '2026-05-03',
|
||||
day: 2,
|
||||
avgManual: 905,
|
||||
avgIot: null,
|
||||
uniformity: 90,
|
||||
difference: null,
|
||||
accuracy: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(summary.metrics.find((m) => m.key === 'beratAvg')?.value).toBe(905);
|
||||
});
|
||||
|
||||
it('places Berat DOC beside Umur from payload.docWeight', () => {
|
||||
const summary = buildReportSummary(emptyPayload({ upToDay: 3, docWeight: 42 }));
|
||||
expect(summary.metrics[0]).toMatchObject({ key: 'umur', value: 3 });
|
||||
expect(summary.metrics[1]).toMatchObject({
|
||||
key: 'docWeight',
|
||||
label: 'Berat DOC',
|
||||
value: 42,
|
||||
unit: 'gram',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../visibility.ts';
|
||||
|
||||
describe('throughVisibleDate', () => {
|
||||
const rows = [{ date: '2026-09-05' }, { date: '2026-09-06' }, { date: '2026-09-07' }];
|
||||
|
||||
it('drops rows after the publish cutoff', () => {
|
||||
expect(throughVisibleDate(rows, '2026-09-06')).toEqual([
|
||||
{ date: '2026-09-05' },
|
||||
{ date: '2026-09-06' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all rows when cutoff is missing', () => {
|
||||
expect(throughVisibleDate(rows, undefined)).toEqual(rows);
|
||||
expect(throughVisibleDate(rows, null)).toEqual(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnOrBeforeVisibleDate', () => {
|
||||
it('rejects dates after the cutoff', () => {
|
||||
expect(isOnOrBeforeVisibleDate('2026-09-07', '2026-09-06')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts dates on or before the cutoff', () => {
|
||||
expect(isOnOrBeforeVisibleDate('2026-09-06', '2026-09-06')).toBe(true);
|
||||
expect(isOnOrBeforeVisibleDate('2026-09-05', '2026-09-06')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty dates', () => {
|
||||
expect(isOnOrBeforeVisibleDate(null, '2026-09-06')).toBe(false);
|
||||
expect(isOnOrBeforeVisibleDate(undefined, '2026-09-06')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { weightStandardData } from '../../mockData/weightStandardData.ts';
|
||||
import { getTargetGainForAge, getTargetWeightForAge } from '../weight.ts';
|
||||
|
||||
function rowForDoc(docWeight: number) {
|
||||
return weightStandardData.data.find((row) => row.day === docWeight)!;
|
||||
}
|
||||
|
||||
describe('getTargetWeightForAge', () => {
|
||||
it('uses the exact DOC-weight row and age column', () => {
|
||||
const row = rowForDoc(42);
|
||||
expect(getTargetWeightForAge(1, 42)).toBe(row.values[0]);
|
||||
expect(getTargetWeightForAge(35, 42)).toBe(row.values[34]);
|
||||
});
|
||||
|
||||
it('clamps below-range DOC weight to the 35 g row', () => {
|
||||
const row = rowForDoc(35);
|
||||
expect(getTargetWeightForAge(7, 20)).toBe(row.values[6]);
|
||||
expect(getTargetWeightForAge(7, 30)).toBe(row.values[6]);
|
||||
});
|
||||
|
||||
it('clamps above-range DOC weight to the 51 g row', () => {
|
||||
const row = rowForDoc(51);
|
||||
expect(getTargetWeightForAge(7, 52)).toBe(row.values[6]);
|
||||
expect(getTargetWeightForAge(7, 60)).toBe(row.values[6]);
|
||||
});
|
||||
|
||||
it('returns null for ages outside 1..35', () => {
|
||||
expect(getTargetWeightForAge(0, 42)).toBeNull();
|
||||
expect(getTargetWeightForAge(36, 42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTargetGainForAge', () => {
|
||||
it('returns day-over-day delta from the DOC-weight row', () => {
|
||||
const row = rowForDoc(40);
|
||||
expect(getTargetGainForAge(2, 40)).toBe(row.values[1]! - row.values[0]!);
|
||||
});
|
||||
|
||||
it('returns null for age 1', () => {
|
||||
expect(getTargetGainForAge(1, 40)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user