/** * D1 Analytics engine unit tests (ADVANCED_REPORTS_IMPLEMENTATION_PLAN.md §3, §8.1) * Pure fixtures — no DB, no HTTP. Covers the plan's fixture list: typical multi-zone, * no-flow-controller, SatLoc-style (no xTrack/turn), unsprayed zone, single zone, * boundary-straddling line — plus midnight wrap and mission ≡ zone reconciliation (NFR-3.3). */ const { expect } = require('chai'); const { createMissionAnalytics, plannedAreaM2, todDiff } = require('../helpers/report_util'); // ~11.13 m per 0.0001° of latitude const STEP_DEG = 0.0001, STEP_M = 11.13; function zone(name, lonMin, latMin, lonMax, latMax, area) { return { properties: { name, ...(area ? { area } : {}) }, geometry: { type: 'Polygon', coordinates: [[[lonMin, latMin], [lonMax, latMin], [lonMax, latMax], [lonMin, latMax], [lonMin, latMin]]] } }; } function pt(lat, lon, gpsTime, llnum, sprayStat, extra = {}) { return Object.assign({ lat, lon, gpsTime, llnum, sprayStat, grSpeed: 11.13, xTrack: 0.5, sprayHeight: 4, lminApp: 60, swath: 15 }, extra); } /** n spray-on points heading north from (lat0, lon0), one per second */ function sprayLine({ lat0, lon0, t0, llnum, n = 20, extra = {} }) { const pts = []; for (let i = 0; i < n; i++) pts.push(pt(lat0 + i * STEP_DEG, lon0, t0 + i, llnum, i === 0 ? 3 : 1, extra)); return pts; } /** spray-off travel points (same heading), one per second */ function offRun({ lat0, lon0, t0, llnum, n = 10, extra = {} }) { const pts = []; for (let i = 0; i < n; i++) pts.push(pt(lat0 + i * STEP_DEG, lon0, t0 + i, llnum, 0, extra)); return pts; } function run(zones, pointBatches, opts = {}) { const engine = createMissionAnalytics({ zones, swathWidthM: opts.swathWidthM || 15, excludedAreas: opts.excludedAreas || [] }); for (let b = 0; b < pointBatches.length; b++) { pointBatches[b].forEach(p => engine.push(p)); engine.fileBreak(); } return engine.finish(); } const zoneA = () => zone('Zone A', 0, 0, 0.01, 0.01); const zoneB = () => zone('Zone B', 0.02, 0, 0.03, 0.01); describe('report_util — D1 analytics engine', function () { describe('todDiff', function () { it('plain difference within a day', function () { expect(todDiff(100, 40)).to.equal(60); }); it('corrects the midnight wrap', function () { expect(todDiff(10, 86390)).to.equal(20); }); it('small negative diffs stay negative (out-of-order points, not a wrap)', function () { expect(todDiff(40, 100)).to.equal(-60); }); }); describe('plannedAreaM2', function () { it('prefers the stored properties.area', function () { expect(plannedAreaM2(zone('z', 0, 0, 0.01, 0.01, 5000))).to.equal(5000); }); it('computes from geometry when no stored area (live-data gap)', function () { const a = plannedAreaM2(zone('z', 0, 0, 0.01, 0.01)); // ~1.11 km square ≈ 1.23e6 m² expect(a).to.be.greaterThan(1.1e6).and.lessThan(1.4e6); }); it('nets out an intersecting exclusion zone (mirrors jobUtil.calcTTSprayAreas)', function () { const z = zoneA(); const xcl = zone('hole', 0, 0, 0.01, 0.005); // bottom half of zone A const full = plannedAreaM2(z); const net = plannedAreaM2(z, [xcl]); expect(net).to.be.closeTo(full / 2, full * 0.05); }); it('does not subtract a non-overlapping exclusion zone', function () { const z = zoneA(); const xcl = zoneB(); // disjoint from zone A expect(plannedAreaM2(z, [xcl])).to.be.closeTo(plannedAreaM2(z), 0.001); }); it('ignores a stored properties.area once exclusion zones exist — a pre-exclusion figure can\'t be trusted', function () { const z = zone('z', 0, 0, 0.01, 0.01, 5000); const xcl = zone('hole', 0, 0, 0.01, 0.005); expect(plannedAreaM2(z, [xcl])).to.not.equal(5000); }); }); describe('typical multi-zone mission', function () { // zone A: llnum 1 and 2 with a measured turn between; zone B: llnum 1 again // (same line number in a different zone must NOT merge — keyed by zone+llnum) const batch = [ ...sprayLine({ lat0: 0.001, lon0: 0.002, t0: 1000, llnum: 1 }), ...offRun({ lat0: 0.003, lon0: 0.0025, t0: 1020, llnum: 1, n: 15 }), ...sprayLine({ lat0: 0.001, lon0: 0.003, t0: 1040, llnum: 2 }), ...sprayLine({ lat0: 0.001, lon0: 0.025, t0: 1100, llnum: 1 }), ]; const res = run([zoneA(), zoneB()], [batch]); it('produces one row per zone+llnum, ordered by start time', function () { expect(res.lines).to.have.length(3); expect(res.lines.map(l => l.llnum)).to.deep.equal([1, 2, 1]); expect(res.lines[0].zoneIdx).to.equal(0); expect(res.lines[1].zoneIdx).to.equal(0); expect(res.lines[2].zoneIdx).to.equal(1); }); it('computes line length/area from geometry × swath', function () { const l = res.lines[0]; expect(l.lengthM).to.be.closeTo(19 * STEP_M, 2); expect(l.areaM2).to.be.closeTo(l.lengthM * 15, 1); expect(l.sprayTimeS).to.equal(19); expect(l.avgSpeedMps).to.be.closeTo(11.13, 0.01); }); it('measures the turn between line 1 and line 2 (5–120 s window)', function () { expect(res.lines[0].turnTimeS).to.equal(20); // off at t=1020, next spray-on at t=1040 }); it('does not leak that turn onto zone B\'s unrelated reused llnum 1 (no off-run precedes it)', function () { expect(res.lines[2].zoneIdx).to.equal(1); expect(res.lines[2].llnum).to.equal(1); expect(res.lines[2].turnTimeS).to.be.null; }); it('rolls zones up from their lines', function () { const [a, b] = res.zones; expect(a.lineCount).to.equal(2); expect(b.lineCount).to.equal(1); expect(a.sprayedAreaM2).to.be.closeTo(res.lines[0].areaM2 + res.lines[1].areaM2, 0.001); expect(a.avgTurnTimeS).to.equal(20); expect(b.avgTurnTimeS).to.be.null; // zone B's reused llnum 1 has no measured turn of its own expect(a.volumeL).to.be.closeTo(38, 2); // 2 lines × (60 L/min over 19 s) }); it('mission totals reconcile exactly with zone roll-ups (NFR-3.3)', function () { const zoneAreaSum = res.zones.reduce((acc, z) => acc + z.sprayedAreaM2, 0); const zoneVolSum = res.zones.reduce((acc, z) => acc + (z.volumeL || 0), 0); const zoneSpraySum = res.zones.reduce((acc, z) => acc + z.sprayTimeS, 0); expect(res.mission.sprayedAreaM2).to.equal(zoneAreaSum); expect(res.mission.volumeL).to.equal(zoneVolSum); expect(res.mission.sprayTimeS).to.equal(zoneSpraySum); expect(res.mission.sprayDistanceM).to.be.closeTo(res.lines.reduce((a, l) => a + l.lengthM, 0), 0.001); expect(res.mission.zonesSprayed).to.equal(2); expect(res.mission.zonesTotal).to.equal(2); expect(res.mission.lineCount).to.equal(3); }); it('ferry figures are total minus spray, never negative', function () { expect(res.mission.ferryTimeS).to.equal(res.mission.totalFlightS - res.mission.sprayTimeS); expect(res.mission.ferryDistanceM).to.be.at.least(0); }); }); describe('avgSpraySpeed includes the line-start marker (sprayStat 3)', function () { // legacy job_worker.js:1470-1472 accumulates speed for every sprayStat>0 record with no // sprayStat!==3 exclusion (that exclusion applies only to the spray-time accumulator) — // AGGREGATED_FIELDS_CALCULATION.md's prose description of this rule is wrong; verified // against the actual legacy code, not the doc const pts = [pt(0.001, 0.002, 0, 1, 3, { grSpeed: 100 })]; for (let i = 1; i < 10; i++) pts.push(pt(0.001 + i * STEP_DEG, 0.002, i, 1, 1, { grSpeed: 11.13 })); const res = run([zoneA()], [pts]); it('includes the marker point\'s grSpeed (matches the actual legacy avgSpraySpeed code)', function () { const expected = (100 + 9 * 11.13) / 10; expect(res.lines[0].avgSpeedMps).to.be.closeTo(expected, 0.01); }); }); describe('no-flow-controller job (lminApp flat 0)', function () { const batch = sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, extra: { lminApp: 0 } }); const res = run([zoneA()], [batch]); it('volume and flow degrade to null, everything else computes', function () { expect(res.lines[0].volumeL).to.be.null; expect(res.zones[0].volumeL).to.be.null; expect(res.zones[0].avgFlowLmin).to.be.null; expect(res.mission.volumeL).to.be.null; expect(res.mission.avgFlowLmin).to.be.null; expect(res.lines[0].lengthM).to.be.greaterThan(0); }); }); describe('SatLoc-style job (no xTrack, no height, no turns)', function () { const batch = sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, extra: { xTrack: 0, sprayHeight: 0 } }); const res = run([zoneA()], [batch]); it('XT / height / turn degrade to null', function () { expect(res.lines[0].avgXtM).to.be.null; expect(res.lines[0].avgHeightM).to.be.null; expect(res.lines[0].turnTimeS).to.be.null; expect(res.zones[0].avgXtM).to.be.null; expect(res.mission.avgXtM).to.be.null; }); }); describe('mission with an exclusion zone (planned area matches Job.ttSprArea)', function () { const xcl = zone('pond', 0, 0, 0.01, 0.005); // bottom half of zone A const res = run([zoneA(), zoneB()], [sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })], { excludedAreas: [xcl] }); const noXclRes = run([zoneA(), zoneB()], [sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })]); it('nets the exclusion zone out of the overlapping zone only', function () { expect(res.zones[0].plannedAreaM2).to.be.lessThan(noXclRes.zones[0].plannedAreaM2); expect(res.zones[1].plannedAreaM2).to.be.closeTo(noXclRes.zones[1].plannedAreaM2, 0.001); }); it('mission planned area is still the exact sum of the netted zone areas (NFR-3.3)', function () { const zoneSum = res.zones.reduce((acc, z) => acc + z.plannedAreaM2, 0); expect(res.mission.plannedAreaM2).to.equal(zoneSum); }); }); describe('unsprayed zone (FR-4.6 dash page)', function () { const res = run([zoneA(), zoneB()], [sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })]); it('keeps the zone in the roll-ups with zero activity', function () { const b = res.zones[1]; expect(b.lineCount).to.equal(0); expect(b.sprayedAreaM2).to.equal(0); expect(b.coveragePct).to.equal(0); expect(b.plannedAreaM2).to.be.greaterThan(0); expect(res.mission.zonesSprayed).to.equal(1); expect(res.mission.zonesTotal).to.equal(2); }); it('mission coveragePct never reaches 100% while an untouched zone remains (an overlapped zone cannot stand in for it)', function () { // zone A gets heavily oversprayed (huge swath -> far more than its own planned area); // zone B (untouched) has zero activity. An unclamped sum would let zone A's excess mask // zone B entirely, misreporting the mission as fully covered. const overspray = run([zoneA(), zoneB()], [sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, n: 2, extra: { swath: 400000 } })]); expect(overspray.zones[0].sprayedAreaM2).to.be.greaterThan(overspray.zones[0].plannedAreaM2); expect(overspray.mission.zonesSprayed).to.equal(1); expect(overspray.mission.zonesTotal).to.equal(2); expect(overspray.mission.coveragePct).to.be.closeTo(50, 0.01); // zone A capped at its own 100%, zone B at 0% }); it('a zone\'s own coveragePct is NOT capped at 100% — real overspray (overlap, turns) should show as such', function () { const overspray = run([zoneA(), zoneB()], [sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, n: 2, extra: { swath: 400000 } })]); expect(overspray.zones[0].coveragePct).to.be.greaterThan(100); expect(overspray.zones[1].coveragePct).to.equal(0); // untouched zone (has a planned area, zero sprayed) stays 0% }); }); describe('boundary-straddling line (FR-5.2 majority rule)', function () { // 20 points heading EAST from inside zone A across its edge: 12 in, 8 out const pts = []; for (let i = 0; i < 20; i++) pts.push(pt(0.005, 0.0088 + i * STEP_DEG, i, 7, i === 0 ? 3 : 1)); const res = run([zoneA(), zoneB()], [pts]); it('assigns the whole line to the majority zone', function () { expect(res.lines).to.have.length(1); expect(res.lines[0].zoneIdx).to.equal(0); }); }); describe('genuine boundary-straddling line crossing INTO a different zone (FR-5.2 refinement: split by zone instead of majority-take-all)', function () { // heads east from inside zone A, through the unzoned gap between A and B, into zone B — // unlike the majority-rule case above (which leaves a zone into empty space and stays // whole), this one actually touches TWO real zones and must be split const pts = [ pt(0.005, 0.003, 0, 9, 3), pt(0.005, 0.005, 1, 9, 1), pt(0.005, 0.007, 2, 9, 1), pt(0.005, 0.012, 3, 9, 1), // gap — no zone, rides along with zone A's run pt(0.005, 0.018, 4, 9, 1), // gap — no zone, rides along with zone A's run pt(0.005, 0.022, 5, 9, 1), pt(0.005, 0.025, 6, 9, 1), pt(0.005, 0.028, 7, 9, 1) ]; const res = run([zoneA(), zoneB()], [pts]); it('produces two line rows — one per zone actually crossed — instead of handing the whole pass to one zone', function () { expect(res.lines).to.have.length(2); expect(res.lines.map(l => l.zoneIdx).sort()).to.deep.equal([0, 1]); expect(res.lines.every(l => l.llnum === 9)).to.equal(true); expect(res.lines.every(l => l.lengthM > 0)).to.equal(true); }); it('mission totals still reconcile exactly with the split zone roll-ups (NFR-3.3)', function () { const zoneAreaSum = res.zones.reduce((acc, z) => acc + z.sprayedAreaM2, 0); expect(res.mission.sprayedAreaM2).to.equal(zoneAreaSum); expect(res.mission.zonesSprayed).to.equal(2); }); }); describe('line fully outside every zone', function () { const res = run([zoneA(), zoneB()], [sprayLine({ lat0: 0.05, lon0: 0.05, t0: 0, llnum: 3 })]); it('falls back to the nearest zone so totals still reconcile', function () { expect(res.lines[0].zoneIdx).to.be.oneOf([0, 1]); const zoneAreaSum = res.zones.reduce((acc, z) => acc + z.sprayedAreaM2, 0); expect(res.mission.sprayedAreaM2).to.equal(zoneAreaSum); }); }); describe('line implausibly far from every zone (Job #95 real-world case: a flight recorded ~1,400km away)', function () { // zoneA sits near the equator; this line is at 44°N, thousands of km from either zone — // nearestZone() must refuse to force an assignment rather than silently attribute a real, // unrelated flight to whichever zone happens to be "least wrong" const res = run([zoneA(), zoneB()], [sprayLine({ lat0: 44.35, lon0: -81.0, t0: 0, llnum: 65535 })]); it('is excluded from every zone, not force-assigned to the least-implausible one', function () { expect(res.lines[0].zoneIdx).to.equal(-1); expect(res.zones[0].lineCount).to.equal(0); expect(res.zones[1].lineCount).to.equal(0); expect(res.zones[0].sprayedAreaM2).to.equal(0); }); it('is summarized separately in mission.unassigned instead of vanishing', function () { expect(res.mission.unassigned.lineCount).to.equal(1); expect(res.mission.unassigned.sprayTimeS).to.equal(res.lines[0].sprayTimeS); expect(res.mission.unassigned.lengthM).to.be.closeTo(res.lines[0].lengthM, 0.001); }); it('does not leak into mission-level zone-derived totals', function () { expect(res.mission.sprayedAreaM2).to.equal(0); expect(res.mission.sprayDistanceM).to.equal(0); expect(res.mission.zonesSprayed).to.equal(0); }); }); describe('midnight wrap', function () { const batch = sprayLine({ lat0: 0.001, lon0: 0.002, t0: 86390, llnum: 1 }); // wraps at i=10 // gpsTime must wrap 86399 -> 0 batch.forEach(p => { p.gpsTime = p.gpsTime % 86400; }); const res = run([zoneA()], [batch]); it('spray time stays positive across the wrap', function () { expect(res.lines[0].sprayTimeS).to.equal(19); expect(res.mission.totalFlightS).to.equal(19); }); }); describe('line order across a midnight wrap (FR-4.5)', function () { // line 1 flies before midnight (large raw gpsTime), line 2 flies after (small raw gpsTime) — // a plain numeric sort on startTimeS would wrongly put line 2 first const beforeMidnight = sprayLine({ lat0: 0.001, lon0: 0.002, t0: 86390, llnum: 1, n: 5 }); const afterMidnight = sprayLine({ lat0: 0.001, lon0: 0.003, t0: 10, llnum: 2, n: 5 }); const res = run([zoneA()], [[...beforeMidnight, ...afterMidnight]]); it('keeps the pre-midnight line before the post-midnight line', function () { expect(res.lines.map(l => l.llnum)).to.deep.equal([1, 2]); expect(res.lines[0].startTimeS).to.equal(86390); expect(res.lines[1].startTimeS).to.equal(10); }); }); describe('multiple files', function () { const res = run([zoneA()], [ sprayLine({ lat0: 0.001, lon0: 0.002, t0: 1000, llnum: 1 }), sprayLine({ lat0: 0.001, lon0: 0.004, t0: 5000, llnum: 2 }), ]); it('flight time sums consecutive-point deltas — the inter-file gap is not flight time', function () { expect(res.mission.totalFlightS).to.equal(38); // 19 + 19, not 4019 expect(res.lines).to.have.length(2); }); }); describe('total flight time matches legacy\'s capped-delta convention (workers/job_worker.js:1447-1455)', function () { // one continuous file: a spray segment, a 600s ground pause (refuel, GPS dropout — no // fileBreak() in between), then more spraying. Legacy sums consecutive-point deltas and // excludes any single gap >120s entirely, rather than taking a wall-clock first-to-last span. const batch = [ ...sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, n: 5 }), ...sprayLine({ lat0: 0.001, lon0: 0.003, t0: 605, llnum: 1, n: 5 }), ]; const res = run([zoneA()], [batch]); it('excludes the internal >120s gap from totalFlightS entirely', function () { expect(res.mission.totalFlightS).to.equal(8); // 4 + 4, not 609 }); }); describe('volume integration also excludes a >120s gap (same legacy cap, all time accumulators)', function () { // one continuous pass (same llnum, no marker reappears, no distance jump — so it never // splits) with a 300s gap between two of its points, both with real lminApp readings const pts = [ pt(0.001, 0.002, 0, 1, 3, { lminApp: 60 }), pt(0.0011, 0.002, 1, 1, 1, { lminApp: 60 }), // interval: dt=1s, avg 60 -> 1 L pt(0.0011, 0.002, 301, 1, 1, { lminApp: 60 }), // interval: dt=300s (>120s) -> must be excluded pt(0.0012, 0.002, 302, 1, 1, { lminApp: 60 }), // interval: dt=1s, avg 60 -> 1 L ]; const res = run([zoneA()], [pts]); it('excludes the 300s-gap interval\'s contribution from volumeL', function () { expect(res.lines[0].volumeL).to.be.closeTo(2, 0.001); // 1 + 1, not 1 + 300 + 1 }); }); describe('single-zone job', function () { const res = run([zoneA()], [sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })]); it('zone roll-up equals mission totals', function () { expect(res.mission.sprayedAreaM2).to.equal(res.zones[0].sprayedAreaM2); expect(res.mission.sprayTimeS).to.equal(res.zones[0].sprayTimeS); expect(res.mission.coveragePct).to.be.closeTo(res.zones[0].coveragePct, 0.0001); }); }); describe('first line logged with the llnum sentinel (Job #90 real-world case: first line recorded as 65535 instead of 1)', function () { const res = run([zoneA()], [ [ ...sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 65535 }), ...sprayLine({ lat0: 0.001, lon0: 0.0035, t0: 30, llnum: 2 }) ] ]); it('normalizes the sentinel line number to 1, not 65535', function () { expect(res.lines[0].llnum).to.equal(1); expect(res.lines.some(l => l.llnum === 65535)).to.equal(false); }); it('keeps it as a distinct line from a genuine line 2', function () { expect(res.lines.length).to.equal(2); expect(res.lines[1].llnum).to.equal(2); }); }); describe('degenerate zero-length line (real Job #106 case: a single-point boundary fragment produced a nonsense "0 ft / 0 ac" report row)', function () { // two points at the exact same position and gpsTime — a real (length()>1) pass, but // with zero length and zero spray time const res = run([zoneA()], [ [pt(0.001, 0.002, 1000, 1, 3), pt(0.001, 0.002, 1000, 1, 1)] ]); it('is dropped from the reported lines instead of showing a 0 ft / 0 ac row', function () { expect(res.lines).to.have.length(0); }); it('does not inflate the zone\'s lineCount', function () { expect(res.zones[0].lineCount).to.equal(0); }); }); });