mega-plant/lib/plant-site-import.test.ts
Dave 1429c8087b feat(plant): vehicle site history from Fuel Logs
/plant and /plant/map import Fuel Logs CSVs by exact QR and show each
vehicle's current and previous distinct site with the dates they were
observed.

- plant_site_observations is durable, QR-keyed history. It is never wiped
  and a previous site is never manufactured.
- An older or repeated snapshot must not move a vehicle backwards; untimed
  conflicting sites on one day are held for review.
- A manual site change shares the same history and the same lock.
- Current/previous metadata and the sticker QR are captured BEFORE the asset
  rows are deleted in the seed, so a re-seed cannot wipe them. Explicit
  empty values are preserved too: clearing a site is a deliberate manual
  observation, not permission to restore an old sheet value.
- Imports change location metadata only. They never touch income, cost or
  fuel measurements.
- A new named site is created only for a matched Plant vehicle. Missing
  coordinates mean "awaiting a pin", never a headquarters fallback.

Owner-confirmed 2026-11-04: TLB06's QR is 1089, not the old meter reading
4704. lib/plant-site-policy.json holds the confirmed aliases (Equestria
Bastion Job -> Equestria 215, Cosmo City / Lion Park -> Cosmo City); no
other identity is guessed.
2026-11-05 00:18:24 +02:00

181 lines
8.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import type PocketBase from 'pocketbase';
import { applyPlantSites, previewPlantSites, SITE_EVENTS, SITE_LOCKS, updateAssetWithSiteHistory, withPlantSiteLock } from './plant-site-import';
type Row = { id: string; [key: string]: unknown };
class FakePb {
data: Record<string, Row[]> = {
assets: [
{ id: 'asset1', code: 'PLANT01', name: 'Exact one', qr_code: '1001', site: 'siteA' },
{ id: 'asset2', code: 'PLANT02', name: 'Exact two', qr_code: '1002', site: '' },
],
sites: [{ id: 'siteA', name: 'Yard A', latitude: -25, longitude: 28 }, { id: 'siteB', name: 'Yard B' }],
[SITE_EVENTS]: [], [SITE_LOCKS]: [],
};
writes: { collection: string; operation: string; id: string }[] = [];
private serial = 0;
filter(_expression: string, values: Record<string, unknown>) { return JSON.stringify(values); }
collection(name: string) {
const rows = () => {
if (!this.data[name]) throw Object.assign(new Error(`Missing collection ${name}`), { status: 404 });
return this.data[name];
};
const find = (id: string) => {
const row = rows().find(r => r.id === id);
if (!row) throw Object.assign(new Error('Not found'), { status: 404 });
return row;
};
return {
getFullList: async (options?: { filter?: string }) => {
const all = rows();
const qr = options?.filter ? JSON.parse(options.filter).qr : undefined;
return structuredClone(qr === undefined ? all : all.filter(r => r.vehicle_qr === qr));
},
getOne: async (id: string) => structuredClone(find(id)),
create: async (input: Record<string, unknown>) => {
const row = structuredClone({ ...input, id: String(input.id || `new${++this.serial}`) });
if (rows().some(r => r.id === row.id)) throw Object.assign(new Error('Duplicate id'), { status: 400 });
rows().push(row);
this.writes.push({ collection: name, operation: 'create', id: row.id });
return structuredClone(row);
},
update: async (id: string, patch: Record<string, unknown>) => {
Object.assign(find(id), structuredClone(patch));
this.writes.push({ collection: name, operation: 'update', id });
return structuredClone(find(id));
},
delete: async (id: string) => {
find(id);
this.data[name] = rows().filter(r => r.id !== id);
this.writes.push({ collection: name, operation: 'delete', id });
},
};
}
asPb() { return this as unknown as PocketBase; }
}
const csv = (...rows: string[]) => ['Date Logged,Vehicle QR,Vehicle,Current Site', ...rows].join('\n');
const historyCsv = csv(
'01/09/2026,1001,Exact one,Yard A',
'03/09/2026,1001,Exact one,Yard B',
'05/09/2026,1001,Exact one,Yard B',
);
async function apply(fake: FakePb, text: string) {
const preview = await previewPlantSites(fake.asPb(), text, 'source.csv');
return applyPlantSites(fake.asPb(), text, 'source.csv', preview.fileHash);
}
test('preview uses exact QR, reports unknown vehicles, and never writes', async () => {
const fake = new FakePb();
const source = csv('01/09/2026,1001,Renamed upstream,Yard B', '01/09/2026,01001,Exact one,Yard A');
const preview = await previewPlantSites(fake.asPb(), source, 'source.csv');
assert.equal(preview.matchedVehicles, 1);
assert.equal(preview.canApply, true);
assert.equal(preview.ignoredRows, 1);
assert.deepEqual(preview.unknownQrs, [{ qr: '01001', name: 'Exact one', rows: 1 }]);
assert.equal(preview.changes[0].currentSite, 'Yard B');
assert.equal(preview.changes[0].needsCoordinates, true);
assert.equal(fake.writes.length, 0);
});
test('apply records repeated-site observations, keeps previous distinct site, and is idempotent', async () => {
const fake = new FakePb();
const first = await apply(fake, historyCsv);
assert.equal(first.appliedVehicles, 1);
assert.equal(first.insertedObservations, 3);
const asset = fake.data.assets[0];
assert.equal(asset.site, 'siteB');
assert.equal(asset.site_label, 'Yard B');
assert.equal(asset.previous_site, 'siteA');
assert.equal(asset.previous_site_label, 'Yard A');
assert.equal(asset.site_observed_on, '2026-09-05');
assert.equal(asset.previous_site_observed_on, '2026-09-01');
const again = await apply(fake, historyCsv);
assert.equal(again.canApply, false);
assert.equal(again.appliedVehicles, 0);
assert.equal(again.insertedObservations, 0);
assert.equal(fake.data[SITE_EVENTS].length, 3);
assert.equal(fake.data[SITE_LOCKS].length, 0);
});
test('older imports enrich history without moving a vehicle backwards', async () => {
const fake = new FakePb();
await apply(fake, csv('05/09/2026,1001,Exact one,Yard B'));
const result = await apply(fake, csv('01/09/2026,1001,Exact one,Yard A'));
assert.equal(result.insertedObservations, 1);
assert.equal(fake.data.assets[0].site, 'siteB');
assert.equal(fake.data.assets[0].site_observed_on, '2026-09-05');
assert.equal(fake.data.assets[0].previous_site_label, 'Yard A');
});
test('new named sites have no fabricated coordinates and upstream-only vehicles are never created', async () => {
const fake = new FakePb();
const result = await apply(fake, csv('05/09/2026,1001,Exact one,New real yard', '05/09/2026,9999,Upstream truck,Other site'));
assert.deepEqual(result.newSites, ['New real yard']);
const site = fake.data.sites.find(s => s.name === 'New real yard')!;
assert.ok(site);
assert.equal(site.latitude, undefined);
assert.equal(site.longitude, undefined);
assert.equal(fake.data.assets.length, 2);
assert.equal(fake.data[SITE_EVENTS].length, 1);
assert.equal(fake.data.assets[0].site, site.id);
});
test('ambiguous latest dates hold one vehicle while clean vehicles apply', async () => {
const fake = new FakePb();
const source = csv('05/09/2026,1001,Exact one,Yard A', '05/09/2026,1001,Exact one,Yard B', '05/09/2026,1002,Exact two,Yard B');
const preview = await previewPlantSites(fake.asPb(), source, 'source.csv');
assert.equal(preview.canApply, true);
assert.deepEqual(preview.issues.map(i => i.qr), ['1001']);
assert.equal(preview.pendingObservations, 1);
const result = await applyPlantSites(fake.asPb(), source, 'source.csv', preview.fileHash);
assert.equal(result.heldVehicles, 1);
assert.equal(fake.data.assets[0].site, 'siteA');
assert.equal(fake.data.assets[1].site, 'siteB');
assert.deepEqual(fake.data[SITE_EVENTS].map(e => e.vehicle_qr), ['1002']);
const verify = await previewPlantSites(fake.asPb(), source, 'source.csv');
assert.equal(verify.canApply, false);
assert.equal(verify.pendingObservations, 0);
assert.equal(verify.issues.length, 1);
});
test('manual moves and explicit clearing survive later imports of older evidence', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: new Date('2026-10-02T10:00:00Z') });
const fake = new FakePb();
await apply(fake, historyCsv);
await updateAssetWithSiteHistory(fake.asPb(), 'asset1', { site: 'siteA' });
assert.equal(fake.data.assets[0].site_source, 'manual');
assert.equal(fake.data.assets[0].site, 'siteA');
assert.equal(fake.data.assets[0].previous_site_label, 'Yard B');
await apply(fake, historyCsv);
assert.equal(fake.data.assets[0].site, 'siteA');
t.mock.timers.tick(1000);
await updateAssetWithSiteHistory(fake.asPb(), 'asset1', { site: '' });
assert.equal(fake.data.assets[0].site, '');
assert.equal(fake.data.assets[0].site_label, '');
await apply(fake, historyCsv);
assert.equal(fake.data.assets[0].site, '');
assert.equal(fake.data.assets[0].site_source, 'manual');
});
test('file hash mismatches reject before acquiring a lock or writing', async () => {
const fake = new FakePb();
await assert.rejects(applyPlantSites(fake.asPb(), historyCsv, 'source.csv', 'bad-hash'), /file changed/i);
assert.equal(fake.writes.length, 0);
});
test('concurrent lock holders are refused and failures release the owned lock', async () => {
const fake = new FakePb();
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
let acquired!: () => void;
const started = new Promise<void>(resolve => { acquired = resolve; });
const first = withPlantSiteLock(fake.asPb(), async () => { acquired(); await gate; });
await started;
await assert.rejects(withPlantSiteLock(fake.asPb(), async () => assert.fail('must not enter')), /Another site update/);
assert.equal(fake.data[SITE_LOCKS].length, 1);
release(); await first;
assert.equal(fake.data[SITE_LOCKS].length, 0);
await assert.rejects(withPlantSiteLock(fake.asPb(), async () => { throw new Error('planned failure'); }), /planned failure/);
assert.equal(fake.data[SITE_LOCKS].length, 0);
});