/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.
181 lines
8.6 KiB
TypeScript
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);
|
|
});
|