Back to home page

EIC code displayed by LXR

 
 

    


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 });