466 lines
21 KiB
JavaScript
466 lines
21 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|
||
});
|
||
});
|