import { describe, it, expect } from 'vitest'; import { parseDecimalWithUnit, ftToM, round2, mapStatus, parseMapData, toNumberish, extractNumerics, mapRow, buildPlan, type ExistingBerthRow, type ImportedBerth, } from '@/lib/services/berth-import'; import type { NocoDbRow } from '@/lib/dedup/nocodb-source'; // ─── parseDecimalWithUnit ──────────────────────────────────────────────────── describe('parseDecimalWithUnit', () => { it('returns null for null/undefined', () => { expect(parseDecimalWithUnit(null)).toBeNull(); expect(parseDecimalWithUnit(undefined)).toBeNull(); }); it('returns finite numbers as-is', () => { expect(parseDecimalWithUnit(63)).toBe(63); expect(parseDecimalWithUnit(63.5)).toBe(63.5); expect(parseDecimalWithUnit(0)).toBe(0); }); it('rejects NaN / Infinity', () => { expect(parseDecimalWithUnit(Number.NaN)).toBeNull(); expect(parseDecimalWithUnit(Number.POSITIVE_INFINITY)).toBeNull(); }); it('parses bare numeric strings', () => { expect(parseDecimalWithUnit('42')).toBe(42); expect(parseDecimalWithUnit('42.5')).toBe(42.5); }); it('strips trailing imperial / metric / power units', () => { expect(parseDecimalWithUnit('63ft')).toBe(63); expect(parseDecimalWithUnit('63 ft')).toBe(63); expect(parseDecimalWithUnit('19 metres')).toBe(19); expect(parseDecimalWithUnit('330kw')).toBe(330); expect(parseDecimalWithUnit('480 V')).toBe(480); }); it('strips currency markers', () => { expect(parseDecimalWithUnit('100 USD')).toBe(100); expect(parseDecimalWithUnit('100$')).toBe(100); }); it('returns null on unparseable strings', () => { expect(parseDecimalWithUnit('approx 60ft')).toBeNull(); expect(parseDecimalWithUnit('na')).toBeNull(); }); it('returns null on non-string non-number inputs', () => { expect(parseDecimalWithUnit({})).toBeNull(); expect(parseDecimalWithUnit([1])).toBeNull(); expect(parseDecimalWithUnit(true)).toBeNull(); }); }); // ─── round2 + ftToM ────────────────────────────────────────────────────────── describe('round2', () => { it('rounds to 2 decimals', () => { expect(round2(1.234)).toBe(1.23); expect(round2(1.235)).toBe(1.24); expect(round2(1)).toBe(1); }); it('passes null through', () => { expect(round2(null)).toBeNull(); }); }); describe('ftToM', () => { it('converts feet to meters at 0.3048', () => { expect(ftToM(100)).toBe(30.48); expect(ftToM(206.69)).toBeCloseTo(63.0, 1); }); it('rounds to 2 decimals', () => { expect(ftToM(45)).toBe(13.72); }); it('passes null through', () => { expect(ftToM(null)).toBeNull(); }); }); // ─── mapStatus ─────────────────────────────────────────────────────────────── describe('mapStatus', () => { it('maps the three known display values', () => { expect(mapStatus('Available')).toBe('available'); expect(mapStatus('Under Offer')).toBe('under_offer'); expect(mapStatus('Sold')).toBe('sold'); }); it('trims whitespace before matching', () => { expect(mapStatus(' Sold ')).toBe('sold'); }); it('falls back to available for unknown / null values', () => { expect(mapStatus(null)).toBe('available'); expect(mapStatus(undefined)).toBe('available'); expect(mapStatus('Pending')).toBe('available'); expect(mapStatus(42)).toBe('available'); }); }); // ─── parseMapData ──────────────────────────────────────────────────────────── describe('parseMapData', () => { it('parses a full NocoDB Map Data object', () => { const raw = { path: 'M838.8 897.2h200.74v44.191H838.8z', x: '922.819', y: '930.721', transform: 'translate(0 409.55)', fontSize: '32', }; expect(parseMapData(raw)).toEqual(raw); }); it('accepts numeric x / y / fontSize too', () => { expect(parseMapData({ x: 1, y: 2, fontSize: 32 })).toEqual({ x: 1, y: 2, fontSize: 32 }); }); it('parses JSON-string Map Data defensively', () => { const raw = JSON.stringify({ path: 'M0 0 L1 1', x: '5', y: '10' }); expect(parseMapData(raw)).toEqual({ path: 'M0 0 L1 1', x: '5', y: '10' }); }); it('returns null for null/empty', () => { expect(parseMapData(null)).toBeNull(); expect(parseMapData(undefined)).toBeNull(); expect(parseMapData('')).toBeNull(); }); it('returns null on shape mismatch (e.g. number where path is required)', () => { expect(parseMapData({ path: 42 })).toBeNull(); }); it('returns null on malformed JSON string', () => { expect(parseMapData('{not valid json')).toBeNull(); }); }); // ─── toNumberish ───────────────────────────────────────────────────────────── describe('toNumberish', () => { it('returns numbers as-is, rejects NaN/Infinity', () => { expect(toNumberish(42)).toBe(42); expect(toNumberish(Number.NaN)).toBeNull(); expect(toNumberish(Number.POSITIVE_INFINITY)).toBeNull(); }); it('parses numeric strings', () => { expect(toNumberish('42')).toBe(42); expect(toNumberish('42.5')).toBe(42.5); }); it('returns null on non-numeric / blank', () => { expect(toNumberish('')).toBeNull(); expect(toNumberish(null)).toBeNull(); expect(toNumberish('apple')).toBeNull(); }); }); // ─── extractNumerics ───────────────────────────────────────────────────────── describe('extractNumerics', () => { it('extracts the full set + computes metric values from imperial', () => { const row: NocoDbRow = { Id: 1, Length: 206.69, Width: 46.56, Draft: 14.5, 'Water Depth': 16.08, 'Nominal Boat Size': 200, 'Power Capacity': 330, Voltage: 480, Price: 3528000, }; const n = extractNumerics(row); expect(n.lengthFt).toBe(206.69); expect(n.widthFt).toBe(46.56); expect(n.draftFt).toBe(14.5); expect(n.lengthM).toBe(63); expect(n.widthM).toBe(14.19); expect(n.draftM).toBe(4.42); expect(n.waterDepth).toBe(16.08); expect(n.waterDepthM).toBe(4.9); expect(n.nominalBoatSize).toBe(200); expect(n.nominalBoatSizeM).toBe(60.96); expect(n.powerCapacity).toBe(330); expect(n.voltage).toBe(480); expect(n.price).toBe(3528000); }); it('handles missing fields', () => { const n = extractNumerics({ Id: 2 }); expect(n.lengthFt).toBeNull(); expect(n.lengthM).toBeNull(); }); it('rounds CRM values to 2 decimals to neutralize NocoDB precision drift', () => { const n = extractNumerics({ Id: 3, Length: 12.3456789 }); expect(n.lengthFt).toBe(12.35); }); }); // ─── mapRow ────────────────────────────────────────────────────────────────── describe('mapRow', () => { const sampleRow: NocoDbRow = { Id: 1, 'Mooring Number': 'A1', Area: 'A', Status: 'Under Offer', Length: 206.69, Width: 46.56, Draft: 14.5, 'Side Pontoon': 'Quay PT', 'Power Capacity': 330, Voltage: 480, 'Mooring Type': 'Side Pier / Med Mooring', 'Cleat Type': 'A5', 'Cleat Capacity': '20-24 ton break load', 'Bollard Type': 'Bull bollard type B', 'Bollard Capacity': '40 ton break load', Access: 'Car (3t) to Vessel', 'Bow Facing': 'East', 'Berth Approved': false, 'Width Is Minimum': false, 'Water Depth': 16.08, 'Water Depth Is Minimum': false, 'Nominal Boat Size': 200, Price: 3528000, status_override_mode: 'auto', 'Map Data': { path: 'M0 0', x: '1', y: '2', transform: '', fontSize: '32' }, }; it('maps a representative NocoDB Berth row end-to-end', () => { const out = mapRow(sampleRow); expect(out).not.toBeNull(); expect(out!.legacyId).toBe(1); expect(out!.mooringNumber).toBe('A1'); expect(out!.status).toBe('under_offer'); expect(out!.area).toBe('A'); expect(out!.numerics.lengthFt).toBe(206.69); expect(out!.numerics.lengthM).toBe(63); expect(out!.statusOverrideMode).toBe('auto'); expect(out!.mapData?.path).toBe('M0 0'); }); it('returns null when Mooring Number is missing', () => { const rest = { ...sampleRow }; delete (rest as Record)['Mooring Number']; const out = mapRow({ ...rest, Id: 99 } as NocoDbRow); expect(out).toBeNull(); }); it('trims whitespace from Mooring Number', () => { const out = mapRow({ ...sampleRow, 'Mooring Number': ' A1 ' }); expect(out?.mooringNumber).toBe('A1'); }); it('coerces Berth Approved to a strict boolean', () => { const a = mapRow({ ...sampleRow, 'Berth Approved': true }); const b = mapRow({ ...sampleRow, 'Berth Approved': null }); const c = mapRow({ ...sampleRow, 'Berth Approved': 'yes' }); expect(a?.berthApproved).toBe(true); expect(b?.berthApproved).toBe(false); expect(c?.berthApproved).toBe(false); }); it('drops malformed Map Data gracefully', () => { const out = mapRow({ ...sampleRow, 'Map Data': { path: 42 } as unknown }); expect(out?.mapData).toBeNull(); }); }); // ─── buildPlan ─────────────────────────────────────────────────────────────── describe('buildPlan', () => { function importedBerth(mooring: string, overrides: Partial = {}): ImportedBerth { return { legacyId: 0, mooringNumber: mooring, area: null, status: 'available', numerics: extractNumerics({ Id: 0 }), widthIsMinimum: false, waterDepthIsMinimum: false, sidePontoon: null, mooringType: null, cleatType: null, cleatCapacity: null, bollardType: null, bollardCapacity: null, access: null, bowFacing: null, berthApproved: false, statusOverrideMode: null, mapData: null, ...overrides, }; } function existing( mooring: string, updatedAt: Date, lastImportedAt: Date | null, ): ExistingBerthRow { return { id: `id-${mooring}`, mooringNumber: mooring, updatedAt, lastImportedAt }; } it('plans inserts for moorings missing from CRM', () => { const { plan, orphans } = buildPlan([importedBerth('A1')], new Map(), false); expect(plan).toEqual([ expect.objectContaining({ action: 'insert', imported: expect.objectContaining({ mooringNumber: 'A1' }), }), ]); expect(orphans).toEqual([]); }); it('plans updates for unedited rows', () => { const map = new Map([ ['A1', existing('A1', new Date('2026-04-01'), new Date('2026-04-02'))], ]); const { plan } = buildPlan([importedBerth('A1')], map, false); expect(plan[0]?.action).toBe('update'); }); it('skips human-edited rows by default', () => { const map = new Map([ ['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))], ]); const { plan } = buildPlan([importedBerth('A1')], map, false); expect(plan[0]?.action).toBe('skip-edited'); expect(plan[0]?.reason).toMatch(/updated_at=.*last_imported_at=/); }); it('honours --force by updating even human-edited rows', () => { const map = new Map([ ['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))], ]); const { plan } = buildPlan([importedBerth('A1')], map, true); expect(plan[0]?.action).toBe('update'); }); it('treats lastImportedAt=null as never-imported, not human-edited (so we update)', () => { const map = new Map([ ['A1', existing('A1', new Date('2026-04-10'), null)], ]); const { plan } = buildPlan([importedBerth('A1')], map, false); expect(plan[0]?.action).toBe('update'); }); it('reports CRM-side moorings missing from the import as orphans', () => { const map = new Map([ ['A1', existing('A1', new Date(), null)], ['Z99', existing('Z99', new Date(), null)], ]); const { plan, orphans } = buildPlan([importedBerth('A1')], map, false); expect(plan).toHaveLength(1); expect(orphans.map((o) => o.mooringNumber)).toEqual(['Z99']); }); it('1-second tolerance: tiny updated_at lead is treated as in-sync, not edited', () => { const lastImported = new Date('2026-04-10T12:00:00.000Z'); const updatedAt = new Date('2026-04-10T12:00:00.500Z'); // 500ms later const map = new Map([ ['A1', existing('A1', updatedAt, lastImported)], ]); const { plan } = buildPlan([importedBerth('A1')], map, false); expect(plan[0]?.action).toBe('update'); }); });