/* * A location's parent is another location or an area — the item panel splits them by lookup. * * Contract 0.9.24 (ops-contract `00a346f`) declares `locations.parentId` as `axis-or-external`: the parent is * another location (line · cell · zone) or, when no location has that id, an area of the space. Before this, the * declaration said "area" only, so a bin under a zone could not walk to its zone, and a zone could not see the * bins under it. * * What is held here: * - a parent that is a location becomes a link to that location, and nothing of it is sent to the area resolver; * - a parent that is not a location is resolved as an area — only those ids reach the resolver; * - a parent id found nowhere comes back as the resolver answers it (missing), not as an empty relation; * - a zone sees the locations under it as incoming, counted from the document (rows carry no parent location). */ import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' const req = createRequire(import.meta.url) const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell') const { entities: authEntities } = req('@things-factory/auth-base') const { entities: twinEntities } = req('../dist-server/service/index.js') const { TwinEngine } = req('../dist-server/engine/index.js') const { TwinModelItemQuery } = req('../dist-server/service/twin-model/twin-model-item-query.js') const { registerExternalResolver, clearExternalResolvers } = req('../dist-server/service/twin-model/external-resolver.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string let q: any let ctx: any const asked: string[][] = [] const INSTANCE = 'parent-probe' const master = (): any => ({ source: INSTANCE, system: 'wms', siteName: 'parent site', space: { width: 80, depth: 40, unit: 'm' }, locations: [ { id: 'hall-a', type: 'storage', role: 'area', name: 'Hall A' }, { id: 'zone-1', type: 'storage', role: 'location', name: 'Zone 1', parentId: 'hall-a' }, { id: 'bin-1', type: 'storage', role: 'location', name: 'Bin 1', parentId: 'zone-1', capacity: 10 }, { id: 'bin-2', type: 'storage', role: 'location', name: 'Bin 2', parentId: 'zone-1', capacity: 10 }, { id: 'bin-x', type: 'storage', role: 'location', name: 'Bin X', parentId: 'nowhere', capacity: 10 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'zone-1' }] }) before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities], logging: false }) await ds.initialize() addDataSource('default', ds) const d = await ds.getRepository(Domain).save({ name: 'parent-test', subdomain: 'parent-test' } as any) domainId = d.id q = new TwinModelItemQuery() ctx = { state: { domain: { id: domainId } } } /* A stand-in for the host's area resolver: it records what it was asked and knows one area. */ registerExternalResolver({ entity: 'space.area', resolve: async (_domainId: string, ids: string[]) => { asked.push([...ids]) return ids.map(id => (id === 'hall-a' ? { id, name: 'Hall A' } : { id, missing: true })) } }) await TwinEngine.ingestMaster(domainId, master(), 'reset') }) after(async () => { clearExternalResolvers() await TwinEngine.stop(domainId, INSTANCE).catch(() => {}) await ds?.destroy() }) const parentLinks = (item: any) => item.links.out.filter((l: any) => l.field === 'parentId') test('a parent that is a location is a link to that location — none of it goes to the area resolver', async () => { asked.length = 0 const bin = await q.twinModelItem(INSTANCE, 'locations', 'bin-1', ctx) const links = parentLinks(bin) assert.equal(links.length, 1, `expected one parent line, got ${JSON.stringify(links)}`) assert.deepEqual(links[0].target, { kind: 'axis', axis: 'locations' }) assert.equal(links[0].declaredTarget.kind, 'axis-or-external') assert.deepEqual(links[0].ids, ['zone-1']) assert.deepEqual(asked, [], 'a location parent was sent to the area resolver') }) test('a parent that is not a location is resolved as an area — only that id reaches the resolver', async () => { asked.length = 0 const zone = await q.twinModelItem(INSTANCE, 'locations', 'zone-1', ctx) const links = parentLinks(zone) assert.equal(links.length, 1) assert.deepEqual(links[0].target, { kind: 'external', entity: 'space.area' }) assert.deepEqual(links[0].refs, [{ id: 'hall-a', name: 'Hall A' }]) assert.deepEqual(asked, [['hall-a']]) }) test('a parent found nowhere is said to be missing — not an empty relation', async () => { const bin = await q.twinModelItem(INSTANCE, 'locations', 'bin-x', ctx) const links = parentLinks(bin) assert.equal(links.length, 1) assert.equal(links[0].target.kind, 'external') assert.equal(links[0].total, 1) assert.deepEqual(links[0].refs, [{ id: 'nowhere', missing: true }]) }) test('a zone sees the locations under it as incoming — counted from the document', async () => { const zone = await q.twinModelItem(INSTANCE, 'locations', 'zone-1', ctx) const under = zone.links.incoming.find((l: any) => l.from === 'locations' && l.field === 'parentId') assert.ok(under, `no incoming parent relation on the zone: ${JSON.stringify(zone.links.incoming.map((l: any) => [l.from, l.field]))}`) assert.deepEqual([...under.ids].sort(), ['bin-1', 'bin-2']) assert.equal(under.total, 2) })