update ai insight migration for all page
This commit is contained in:
1 parent
07d36081ce
commit
18790ab8f6
69 files changed
+9457
-399
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildDashboardInsightMetrics,
|
||||
scopeDashboardInsightMetrics,
|
||||
} from '../insightContextBuilders.ts';
|
||||
import { getTargetWeightForAge } from '../weight.ts';
|
||||
|
||||
describe('buildDashboardInsightMetrics', () => {
|
||||
it('builds latest scalars and day-indexed weight series with targets', () => {
|
||||
const metrics = buildDashboardInsightMetrics({
|
||||
kpis: [
|
||||
{
|
||||
age: 10,
|
||||
fcr: 1.2,
|
||||
eef: 180,
|
||||
chicken_life: 9800,
|
||||
chicken_life_percentage: 98,
|
||||
iot_weight: 0,
|
||||
mortality_total: 200,
|
||||
harvest_total: 0,
|
||||
harvest_weight_total: 0,
|
||||
feed_total: 120,
|
||||
},
|
||||
{
|
||||
age: 28,
|
||||
fcr: 1.55,
|
||||
eef: 260,
|
||||
chicken_life: 9500,
|
||||
chicken_life_percentage: 95,
|
||||
iot_weight: 1400,
|
||||
mortality_total: 500,
|
||||
harvest_total: 100,
|
||||
harvest_weight_total: 150.5,
|
||||
feed_total: 400,
|
||||
},
|
||||
],
|
||||
weights: [
|
||||
{ age: 10, average_weight: 280, doc_weight: 40, uniformity: 82.5 },
|
||||
{ age: 28, average_weight: 1350, doc_weight: 40, uniformity: 78.1 },
|
||||
],
|
||||
currentDay: 30,
|
||||
docInCount: 10000,
|
||||
});
|
||||
|
||||
expect(metrics.hari_ke).toBe(28);
|
||||
expect(metrics.fcr).toBe(1.55);
|
||||
expect(metrics.eef).toBe(260);
|
||||
expect(metrics.ayam_hidup_ekor).toBe(9500);
|
||||
expect(metrics.mortalitas_kumulatif_persen).toBe(5);
|
||||
expect(metrics.bobot_avg_gram).toBe(1400);
|
||||
expect(metrics.doc_in_ekor).toBe(10000);
|
||||
expect(metrics.kematian_kumulatif_ekor).toBe(500);
|
||||
expect(metrics.panen_kumulatif_ekor).toBe(100);
|
||||
expect(metrics.tonase_panen_kg).toBe(150.5);
|
||||
expect(metrics.uniformity_persen).toBe(78.1);
|
||||
expect(metrics.pakan_kumulatif_karung).toBe(400);
|
||||
expect(metrics.weightSeries).toHaveLength(2);
|
||||
expect(metrics.weightSeries[0]).toEqual({
|
||||
hari: 10,
|
||||
actual: 280,
|
||||
target: getTargetWeightForAge(10, 40),
|
||||
});
|
||||
expect(metrics.kpiSeries.map((row) => row.hari)).toEqual([10, 28]);
|
||||
});
|
||||
|
||||
it('falls back to weight average when iot_weight is missing', () => {
|
||||
const metrics = buildDashboardInsightMetrics({
|
||||
kpis: [
|
||||
{
|
||||
age: 7,
|
||||
fcr: 1.1,
|
||||
eef: 100,
|
||||
chicken_life: 9900,
|
||||
chicken_life_percentage: 99,
|
||||
iot_weight: 0,
|
||||
},
|
||||
],
|
||||
weights: [{ age: 7, average_weight: 190, doc_weight: 40 }],
|
||||
pakanKumulatifFallback: 55,
|
||||
});
|
||||
|
||||
expect(metrics.bobot_avg_gram).toBe(190);
|
||||
expect(metrics.pakan_kumulatif_karung).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeDashboardInsightMetrics', () => {
|
||||
const base = buildDashboardInsightMetrics({
|
||||
kpis: [
|
||||
{
|
||||
age: 7,
|
||||
fcr: 1.1,
|
||||
eef: 90,
|
||||
chicken_life: 9900,
|
||||
chicken_life_percentage: 99,
|
||||
iot_weight: 200,
|
||||
mortality_total: 100,
|
||||
harvest_total: 0,
|
||||
harvest_weight_total: 0,
|
||||
feed_total: 40,
|
||||
},
|
||||
{
|
||||
age: 14,
|
||||
fcr: 1.3,
|
||||
eef: 150,
|
||||
chicken_life: 9800,
|
||||
chicken_life_percentage: 98,
|
||||
iot_weight: 450,
|
||||
mortality_total: 200,
|
||||
harvest_total: 50,
|
||||
harvest_weight_total: 80,
|
||||
feed_total: 90,
|
||||
},
|
||||
],
|
||||
weights: [
|
||||
{ age: 7, average_weight: 200, doc_weight: 40, uniformity: 85 },
|
||||
{ age: 14, average_weight: 450, doc_weight: 40, uniformity: 80 },
|
||||
{ age: 21, average_weight: 800, doc_weight: 40, uniformity: 75 },
|
||||
],
|
||||
docInCount: 10000,
|
||||
});
|
||||
|
||||
it('filters weight series and uses exact-day KPI scalars', () => {
|
||||
const scoped = scopeDashboardInsightMetrics(base, 7);
|
||||
expect(scoped.hari_ke).toBe(7);
|
||||
expect(scoped.fcr).toBe(1.1);
|
||||
expect(scoped.bobot_avg_gram).toBe(200);
|
||||
expect(scoped.doc_in_ekor).toBe(10000);
|
||||
expect(scoped.kematian_kumulatif_ekor).toBe(100);
|
||||
expect(scoped.pakan_kumulatif_karung).toBe(40);
|
||||
expect(scoped.uniformity_persen).toBe(85);
|
||||
expect(scoped.weightSeries.map((row) => row.hari)).toEqual([7]);
|
||||
});
|
||||
|
||||
it('does not invent scalars for a day without KPI snapshot', () => {
|
||||
const scoped = scopeDashboardInsightMetrics(base, 21);
|
||||
expect(scoped.hari_ke).toBe(21);
|
||||
expect(scoped.fcr).toBeNull();
|
||||
expect(scoped.eef).toBeNull();
|
||||
expect(scoped.bobot_avg_gram).toBe(800); // from weight series at day 21
|
||||
expect(scoped.kematian_kumulatif_ekor).toBeNull();
|
||||
expect(scoped.doc_in_ekor).toBe(10000); // cycle constant still shown
|
||||
expect(scoped.weightSeries.map((row) => row.hari)).toEqual([7, 14, 21]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { scopeScalarsToTimeframe, UNAVAILABLE } from '../insightScope.ts';
|
||||
|
||||
/**
|
||||
* Real shape of the FCR page's 7-day history for this cycle: the IoT scale
|
||||
* stopped reporting at day 38 and has written 0 ever since, and the flock was
|
||||
* fully harvested on day 48 so `ayam` ends at 0 too.
|
||||
*/
|
||||
const ROWS = [
|
||||
{ day: 42, fcr: 1.8, pakan: 1064, bobot_kg: 0, ayam: 1424 },
|
||||
{ day: 44, fcr: 1.81, pakan: 1067, bobot_kg: 0, ayam: 1314 },
|
||||
{ day: 46, fcr: 1.82, pakan: 1072, bobot_kg: 0, ayam: 1253 },
|
||||
{ day: 47, fcr: 1.78, pakan: 1073, bobot_kg: 1.38, ayam: 822 },
|
||||
{ day: 48, fcr: 1.7, pakan: 1073, bobot_kg: 0, ayam: 0 },
|
||||
];
|
||||
|
||||
const spec = (agg: 'last' | 'lastValid') => ({
|
||||
historyKey: 'tren',
|
||||
derive: {
|
||||
bobot_kg_out: { field: 'bobot_kg', agg },
|
||||
ayam_out: { field: 'ayam', agg },
|
||||
},
|
||||
});
|
||||
|
||||
describe('lastValid aggregation', () => {
|
||||
it('last takes the trailing zero — the reported bug', () => {
|
||||
const out = scopeScalarsToTimeframe({ tren: ROWS }, 'mingguan', spec('last'));
|
||||
expect(out.bobot_kg_out).toBe(0);
|
||||
expect(out.ayam_out).toBe(0);
|
||||
});
|
||||
|
||||
it('lastValid walks back past the zeros', () => {
|
||||
const out = scopeScalarsToTimeframe({ tren: ROWS }, 'mingguan', spec('lastValid'));
|
||||
expect(out.bobot_kg_out).toBe(1.38);
|
||||
expect(out.ayam_out).toBe(822);
|
||||
});
|
||||
|
||||
it('reports unavailable when every reading in the window is zero', () => {
|
||||
const allZero = [
|
||||
{ day: 47, bobot_kg: 0, ayam: 0 },
|
||||
{ day: 48, bobot_kg: 0, ayam: 0 },
|
||||
];
|
||||
const out = scopeScalarsToTimeframe({ tren: allZero }, 'mingguan', spec('lastValid'));
|
||||
expect(out.bobot_kg_out).toBe(UNAVAILABLE);
|
||||
expect(out.ayam_out).toBe(UNAVAILABLE);
|
||||
});
|
||||
|
||||
it('leaves a genuine non-zero final reading alone', () => {
|
||||
const rows = [
|
||||
{ day: 47, bobot_kg: 1.3, ayam: 900 },
|
||||
{ day: 48, bobot_kg: 1.45, ayam: 850 },
|
||||
];
|
||||
const out = scopeScalarsToTimeframe({ tren: rows }, 'mingguan', spec('lastValid'));
|
||||
expect(out.bobot_kg_out).toBe(1.45);
|
||||
expect(out.ayam_out).toBe(850);
|
||||
});
|
||||
});
|
||||
|
||||
describe('harian periode label starts at day 0', () => {
|
||||
it('labels cumulative window as hari ke-0 s/d ke-N even if first row is day 2', () => {
|
||||
const rows = [
|
||||
{ day: 2, fcr: 1.1 },
|
||||
{ day: 5, fcr: 1.2 },
|
||||
{ day: 8, fcr: 1.3 },
|
||||
];
|
||||
const out = scopeScalarsToTimeframe(
|
||||
{ tren: rows, fcr_terakhir: 1.3 },
|
||||
'harian',
|
||||
{
|
||||
historyKey: 'tren',
|
||||
derive: {
|
||||
fcr_out: { field: 'fcr', agg: 'lastValid', replaces: 'fcr_terakhir' },
|
||||
},
|
||||
},
|
||||
8
|
||||
);
|
||||
expect(out.periode).toBe('hari ke-0 s/d ke-8');
|
||||
expect(out.fcr_out).toBe(1.3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { scopeContextToTimeframe, describeTimeframe } from '../insightScope.ts';
|
||||
|
||||
/**
|
||||
* Picking a day in the AI Insight day picker means "the cycle so far, up to
|
||||
* that day" — day 0 through day N — not that day in isolation. A single row
|
||||
* shows no trend and cannot support a cumulative figure such as
|
||||
* mortality-to-date, which is what these cards lead with.
|
||||
*/
|
||||
const ctx = {
|
||||
riwayat: [
|
||||
{ hari: 0, nilai: 5 },
|
||||
{ hari: 1, nilai: 10 },
|
||||
{ hari: 5, nilai: 50 },
|
||||
{ hari: 12, nilai: 120 },
|
||||
{ hari: 30, nilai: 300 },
|
||||
],
|
||||
// Snapshot list: no time dimension, must survive slicing intact.
|
||||
perangkat: [{ nama: 'B1' }, { nama: 'B2' }],
|
||||
bobot: 1234,
|
||||
};
|
||||
|
||||
const daysIn = (scoped: typeof ctx) => scoped.riwayat.map((row) => row.hari);
|
||||
|
||||
describe('scopeContextToTimeframe — jendela kumulatif hari 0..N', () => {
|
||||
it('menyertakan seluruh hari sampai hari yang dipilih', () => {
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).toEqual([0, 1, 5]);
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 12))).toEqual([0, 1, 5, 12]);
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 30))).toEqual([0, 1, 5, 12, 30]);
|
||||
});
|
||||
|
||||
it('tidak menyertakan hari setelah hari yang dipilih', () => {
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).not.toContain(12);
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).not.toContain(30);
|
||||
});
|
||||
|
||||
it('menyertakan hari 0 sebagai awal siklus', () => {
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 1))).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('hari tanpa data sendiri tetap memuat hari-hari sebelumnya', () => {
|
||||
// Hari 7 tidak ada barisnya, tapi jendela 0..7 tetap berisi hari 0, 1, 5.
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 7))).toEqual([0, 1, 5]);
|
||||
});
|
||||
|
||||
it('tetap memakai hari terakhir saja bila tidak ada hari yang diminta', () => {
|
||||
// Perilaku lama, dipakai saat pengguna belum menyentuh dropdown.
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'harian'))).toEqual([30]);
|
||||
});
|
||||
|
||||
it('tidak memotong daftar snapshot dan tidak menyentuh skalar', () => {
|
||||
const scoped = scopeContextToTimeframe(ctx, 'harian', 5);
|
||||
expect(scoped.perangkat).toHaveLength(2);
|
||||
expect(scoped.bobot).toBe(1234);
|
||||
});
|
||||
|
||||
it('tidak mengubah perilaku mingguan', () => {
|
||||
// Jendela 7 hari tetap dihitung dari hari terakhir, apa pun hari yang dipilih.
|
||||
expect(daysIn(scopeContextToTimeframe(ctx, 'mingguan', 5))).toEqual([30]);
|
||||
});
|
||||
|
||||
it('mengenali penamaan field "day" maupun "hari"', () => {
|
||||
const inggris = {
|
||||
riwayat: [
|
||||
{ day: 3, nilai: 1 },
|
||||
{ day: 9, nilai: 2 },
|
||||
],
|
||||
};
|
||||
expect(scopeContextToTimeframe(inggris, 'harian', 3).riwayat.map((r) => r.day)).toEqual([3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTimeframe — label periode', () => {
|
||||
it('menyatakan rentang kumulatif, bukan satu hari', () => {
|
||||
// Menyebut "hari ke-30" saja membuat model melaporkan angka kumulatif
|
||||
// 31 hari seolah-olah itu capaian satu hari.
|
||||
expect(describeTimeframe('harian', 48, 30)).toContain('hari ke-0 s/d ke-30');
|
||||
expect(describeTimeframe('harian', 48, 30)).toContain('kumulatif');
|
||||
});
|
||||
|
||||
it('menandai bahwa periode berakhir bukan di hari ini', () => {
|
||||
expect(describeTimeframe('harian', 48, 30)).toContain('BUKAN hari ini');
|
||||
});
|
||||
|
||||
it('menandai bahwa periode berakhir di hari ini bila memang begitu', () => {
|
||||
expect(describeTimeframe('harian', 30, 30)).toContain('berakhir hari ini');
|
||||
expect(describeTimeframe('harian', 30, 30)).not.toContain('BUKAN hari ini');
|
||||
});
|
||||
|
||||
it('mempertahankan perilaku lama saat tidak ada hari dipilih', () => {
|
||||
expect(describeTimeframe('harian', 30)).toBe('Hari ini saja (hari ke-30)');
|
||||
expect(describeTimeframe('mingguan', 30)).toContain('7 hari terakhir');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user