Warning, /firebird/packages/firebird-ng/display/src/lib/services/selection.service.spec.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * SelectionService routes the one selection (piece name, entity index) both
0003 * ways: a 3D pick resolves the picked object to its entity through the
0004 * painter's stamp, and a selection made anywhere highlights the entity
0005 * through the painter that drew it. Hover highlights travel the same route
0006 * and never take the highlight away from the selected entity.
0007 */
0008 import { TestBed } from '@angular/core/testing';
0009 import { provideZonelessChangeDetection, signal } from '@angular/core';
0010 import { Subject } from 'rxjs';
0011 import { Group, Mesh, Object3D } from 'three';
0012 import type { EntityRef } from '@dexvis/firebird-core';
0013 import { SelectionService } from './selection.service';
0014 import { ThreeService } from './three.service';
0015 import { EventDisplayService } from './event-display.service';
0016 import { DataModelService } from './data-model.service';
0017
0018 type Picked = { track: Object3D; intersection?: unknown };
0019
0020 /** Records highlight calls as 'piece#index' strings, in order. */
0021 class PainterStub {
0022 readonly calls: string[] = [];
0023 constructor(private readonly pieceName: string) {}
0024 highlightEntity(index: number): void { this.calls.push(`+${this.pieceName}#${index}`); }
0025 unhighlightEntity(index: number): void { this.calls.push(`-${this.pieceName}#${index}`); }
0026 }
0027
0028 /** A mesh stamped the way EventPiecePainter.registerEntityObject stamps it. */
0029 function stampedMesh(pieceName: string, entityIndex: number): Mesh {
0030 const mesh = new Mesh();
0031 mesh.userData['pieceName'] = pieceName;
0032 mesh.userData['entityIndex'] = entityIndex;
0033 return mesh;
0034 }
0035
0036 describe('SelectionService', () => {
0037 let selection: SelectionService;
0038 let clicked: Subject<Picked>;
0039 let hovered: Subject<Picked | null>;
0040 let invalidate: ReturnType<typeof vi.fn>;
0041 let currentEntry: ReturnType<typeof signal<unknown>>;
0042 let hits: PainterStub;
0043 let tracks: PainterStub;
0044
0045 const hit = (entityIndex: number): EntityRef => ({ pieceName: 'Hits', entityIndex });
0046
0047 beforeEach(() => {
0048 clicked = new Subject();
0049 hovered = new Subject();
0050 invalidate = vi.fn();
0051 currentEntry = signal<unknown>({ id: 'event_0' });
0052 hits = new PainterStub('Hits');
0053 tracks = new PainterStub('Tracks');
0054 const painters: Record<string, PainterStub> = { Hits: hits, Tracks: tracks };
0055
0056 TestBed.configureTestingModule({
0057 providers: [
0058 provideZonelessChangeDetection(),
0059 { provide: ThreeService, useValue: { trackClicked: clicked, trackHovered: hovered, invalidate } },
0060 { provide: EventDisplayService, useValue: { painterFor: (name: string) => painters[name] ?? null } },
0061 { provide: DataModelService, useValue: { currentEntry } },
0062 ],
0063 });
0064 selection = TestBed.inject(SelectionService);
0065 TestBed.tick();
0066 });
0067
0068 it('selects the entity a 3D click picked, including a child of the stamped object', () => {
0069 const parent = stampedMesh('Tracks', 4);
0070 const child = new Group();
0071 parent.add(child);
0072 clicked.next({ track: child });
0073
0074 expect(selection.selection()).toEqual({ pieceName: 'Tracks', entityIndex: 4 });
0075 expect(selection.selectedPiece()).toBe('Tracks');
0076 expect(tracks.calls).toEqual(['+Tracks#4']);
0077 expect(invalidate).toHaveBeenCalled();
0078 });
0079
0080 it('ignores a click on an object no painter stamped', () => {
0081 clicked.next({ track: new Mesh() });
0082 expect(selection.selection()).toBeNull();
0083 });
0084
0085 it('moves the highlight from the previous selection to the new one', () => {
0086 selection.select(hit(1));
0087 selection.select({ pieceName: 'Tracks', entityIndex: 2 });
0088
0089 expect(hits.calls).toEqual(['+Hits#1', '-Hits#1']);
0090 expect(tracks.calls).toEqual(['+Tracks#2']);
0091 expect(selection.isSelected('Tracks', 2)).toBe(true);
0092 expect(selection.isSelected('Hits', 1)).toBe(false);
0093 });
0094
0095 it('clears the selection and its highlight', () => {
0096 selection.select(hit(1));
0097 selection.clear();
0098 expect(selection.selection()).toBeNull();
0099 expect(hits.calls).toEqual(['+Hits#1', '-Hits#1']);
0100 });
0101
0102 it('focuses a piece without selecting an entity', () => {
0103 selection.selectPiece('Tracks');
0104 expect(selection.selectedPiece()).toBe('Tracks');
0105 expect(selection.selection()).toBeNull();
0106 });
0107
0108 it('highlights the hovered entity and removes the highlight when the pointer leaves', () => {
0109 hovered.next({ track: stampedMesh('Hits', 3) });
0110 hovered.next(null);
0111 expect(hits.calls).toEqual(['+Hits#3', '-Hits#3']);
0112 });
0113
0114 it('keeps the selected entity highlighted while the pointer passes over it and others', () => {
0115 selection.select(hit(1));
0116 hovered.next({ track: stampedMesh('Hits', 1) });
0117 hovered.next({ track: stampedMesh('Hits', 2) });
0118 hovered.next(null);
0119
0120 // Hovering the selected entity changes nothing; the other entity gets its
0121 // own highlight; leaving re-highlights the selection (one-slot painters).
0122 expect(hits.calls).toEqual(['+Hits#1', '+Hits#2', '-Hits#2', '+Hits#1']);
0123 });
0124
0125 it('resets the selection when another event is shown', () => {
0126 selection.select(hit(1));
0127 currentEntry.set({ id: 'event_1' });
0128 TestBed.tick();
0129 expect(selection.selection()).toBeNull();
0130 expect(selection.selectedPiece()).toBeNull();
0131 });
0132 });