Fase 1b: nucleo del matcher geoespacial (caracterizado + testeado)
Caracterizacion del matching viejo de node-red en docs/legacy-matching.md (reglas: candidatas geo $near 1000km, filtro fino geolib dist/1000<=sub.distance, dedupe 500m, content i18n kmnasa/kmvecinal con 'км' cirilico verbatim, sealed via Iron). Modulos: - matcher/content.ts: i18n kmnasa/kmvecinal + km redondeado (Math.round dist/1000*10/10) - matcher/geo.ts: geolib.getDistance (identico al viejo) + isHit - matcher/seal.ts: @hapi/iron (Fe26.2) — COMPATIBLE con el iron@5 de la web (verificado: sello en servicio -> unseal en web round-trip OK) - matcher/matcher.ts: matchFire(fire, ctx) -> docs notifications identicos a node-red; dedupe 500m + 1 notif/usuario/fuego; solo audiencia web/mobile - deps: @hapi/iron, geolib 63 tests en verde (incluye interop CJS/ESM en dist). MongoDB 3.2 -> ingesta por polling (sin change streams), pendiente de cablear.
This commit is contained in:
parent
4f8d866226
commit
b3aeb6108f
9 changed files with 507 additions and 0 deletions
128
test/matcher.test.ts
Normal file
128
test/matcher.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { buildMatchContent, contentKey, kmRounded } from '../src/matcher/content.js';
|
||||
import { distanceMeters, isHit } from '../src/matcher/geo.js';
|
||||
import { matchFire, type MatchContext } from '../src/matcher/matcher.js';
|
||||
import type { FireDoc, SubscriptionDoc } from '../src/matcher/types.js';
|
||||
|
||||
function fire(over: Partial<FireDoc> = {}): FireDoc {
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
lat: 40.4168,
|
||||
lon: -3.7038,
|
||||
when: new Date('2026-07-13T12:00:00Z'),
|
||||
type: 'viirs',
|
||||
createdAt: new Date('2026-07-13T12:05:00Z'),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function sub(over: Partial<SubscriptionDoc> = {}): SubscriptionDoc {
|
||||
const lat = over.location?.lat ?? 40.42;
|
||||
const lon = over.location?.lon ?? -3.70;
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
location: { lat, lon },
|
||||
geo: { type: 'Point', coordinates: [lon, lat] },
|
||||
distance: 40,
|
||||
owner: 'u1',
|
||||
type: 'mobile',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('content', () => {
|
||||
it('maps fire type to i18n key', () => {
|
||||
expect(contentKey('viirs')).toBe('kmnasa');
|
||||
expect(contentKey('modis')).toBe('kmnasa');
|
||||
expect(contentKey('vecinal')).toBe('kmvecinal');
|
||||
});
|
||||
it('rounds km to 1 decimal like the old node', () => {
|
||||
expect(kmRounded(64543)).toBe(64.5);
|
||||
expect(kmRounded(500)).toBe(0.5);
|
||||
});
|
||||
it('builds content verbatim incl. the Cyrillic км', () => {
|
||||
expect(buildMatchContent('viirs', 'es', 12.3)).toBe('🔥 Alerta de posible fuego a 12.3 км de distancia (fuente NASA)');
|
||||
expect(buildMatchContent('viirs', 'en', 5)).toBe('🔥 Alert of possible fire in 5 км of distance (source NASA)');
|
||||
expect(buildMatchContent('vecinal', 'es', 1.1)).toBe('🔥 Alerta vecinal de fuego a 1.1 км de distancia');
|
||||
});
|
||||
it('falls back to es for unknown langs', () => {
|
||||
expect(buildMatchContent('viirs', 'gl', 2)).toContain('lume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('geo', () => {
|
||||
it('computes distance and hit boundary', () => {
|
||||
const s = sub({ location: { lat: 40.4168, lon: -3.7038 }, distance: 1 });
|
||||
const f = fire({ lat: 40.4168, lon: -3.7038 });
|
||||
expect(distanceMeters(s, f)).toBe(0);
|
||||
expect(isHit(s, 0)).toBe(true);
|
||||
});
|
||||
it('respects the radius: just outside is not a hit', () => {
|
||||
const s = sub({ distance: 10 });
|
||||
expect(isHit(s, 10_000)).toBe(true); // exactly 10km
|
||||
expect(isHit(s, 10_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function ctx(over: Partial<MatchContext> = {}): MatchContext {
|
||||
return {
|
||||
candidateSubs: async () => [],
|
||||
existsNotifNear: async () => false,
|
||||
userLang: async () => 'es',
|
||||
ironPassword: 'x'.repeat(32),
|
||||
audienceTypes: new Set(['web', 'mobile']),
|
||||
now: () => new Date('2026-07-13T12:06:00Z'),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('matchFire', () => {
|
||||
it('generates a notification doc identical in shape to node-red', async () => {
|
||||
const f = fire();
|
||||
const s = sub({ owner: 'u1', type: 'mobile', distance: 40 });
|
||||
const notifs = await matchFire(f, ctx({ candidateSubs: async () => [s] }));
|
||||
expect(notifs).toHaveLength(1);
|
||||
const n = notifs[0]!;
|
||||
expect(n.userId).toBe('u1');
|
||||
expect(n.subsId).toBe(s._id);
|
||||
expect(n.type).toBe('mobile');
|
||||
expect(n.geo).toEqual({ type: 'Point', coordinates: [f.lon, f.lat] });
|
||||
expect(n.when).toBe(f.when);
|
||||
expect(n.content).toContain('км');
|
||||
expect(typeof n.sealed).toBe('string');
|
||||
expect(n.sealed.startsWith('Fe26.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips subs outside their radius', async () => {
|
||||
const far = sub({ location: { lat: 41.5, lon: -3.7 }, distance: 1 }); // ~120km away, radius 1km
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [far] }));
|
||||
expect(notifs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('dedupes against existing notifications within 500m', async () => {
|
||||
const s = sub();
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s], existsNotifNear: async () => true }));
|
||||
expect(notifs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits at most one notification per user per fire', async () => {
|
||||
const s1 = sub({ _id: new ObjectId(), owner: 'u1', distance: 50 });
|
||||
const s2 = sub({ _id: new ObjectId(), owner: 'u1', distance: 50 });
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s1, s2] }));
|
||||
expect(notifs).toHaveLength(1);
|
||||
expect(notifs[0]!.subsId).toBe(s1._id); // first hit wins
|
||||
});
|
||||
|
||||
it('ignores subscription types outside the audience (e.g. telegram)', async () => {
|
||||
const tg = sub({ type: 'telegram', chatId: 123 });
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [tg] }));
|
||||
expect(notifs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the owner language for content', async () => {
|
||||
const s = sub({ owner: 'uEN' });
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s], userLang: async () => 'en' }));
|
||||
expect(notifs[0]!.content).toContain('source NASA');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue