import { describe, it, expect } from 'vitest'; import { ServiceCache, type RawServicesMessage } from '../src/services.js'; /** * A small hand-crafted services message that mirrors the shape we'd * get from KRPC.GetServices() on a real KSP install. Just enough * services/procedures/enums to exercise the cache. */ const SAMPLE_RAW: RawServicesMessage = { services: [ { name: 'SpaceCenter', procedures: [ { name: 'GetUT', parameters: [], returnType: { code: 1, service: '', name: '', types: [] }, // DOUBLE returnIsNullable: false, }, { name: 'GetBodies', parameters: [], returnType: { code: 301, // LIST service: '', name: '', types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }], }, returnIsNullable: false, }, { name: 'CelestialBody.GetName', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }, nullable: false, }, ], returnType: { code: 8, service: '', name: '', types: [] }, // STRING returnIsNullable: false, }, { name: 'CelestialBody.GetParent', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }, nullable: false, }, ], // Nullable CelestialBody (i.e. Sun has no parent). returnType: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }, returnIsNullable: true, }, ], classes: [ { name: 'CelestialBody' }, { name: 'Vessel' }, { name: 'Orbit' }, ], enumerations: [ { name: 'VesselType', values: [ { name: 'Ship', value: 0 }, { name: 'Station', value: 1 }, { name: 'Probe', value: 3 }, { name: 'Debris', value: 8 }, ], }, { name: 'VesselSituation', values: [ { name: 'PreLaunch', value: 0 }, { name: 'Orbiting', value: 1 }, { name: 'Escaping', value: 2 }, { name: 'Landed', value: 4 }, { name: 'Splashed', value: 5 }, ], }, ], }, { name: 'KRPC', procedures: [ { name: 'GetStatus', parameters: [], returnType: { code: 203, service: '', name: '', types: [] }, // STATUS returnIsNullable: false, }, ], classes: [], enumerations: [], }, ], }; describe('ServiceCache', () => { it('builds from a raw services message', () => { const cache = new ServiceCache(SAMPLE_RAW); expect(cache.procedureCount()).toBe(5); expect(cache.serviceNames()).toEqual(['KRPC', 'SpaceCenter']); }); it('looks up a top-level procedure', () => { const cache = new ServiceCache(SAMPLE_RAW); const r = cache.lookup('SpaceCenter', 'GetUT'); expect(r.found).toBe(true); if (!r.found) throw new Error('unreachable'); expect(r.info.service).toBe('SpaceCenter'); expect(r.info.name).toBe('GetUT'); expect(r.info.returnType.code).toBe(1); // DOUBLE expect(r.info.returnIsNullable).toBe(false); expect(r.info.parameters).toHaveLength(0); }); it('looks up a class-prefixed procedure', () => { const cache = new ServiceCache(SAMPLE_RAW); const r = cache.lookup('SpaceCenter', 'CelestialBody.GetName'); expect(r.found).toBe(true); if (!r.found) throw new Error('unreachable'); expect(r.info.parameters).toHaveLength(1); expect(r.info.parameters[0]?.name).toBe('self'); expect(r.info.parameters[0]?.type.code).toBe(100); // CLASS expect(r.info.parameters[0]?.type.name).toBe('CelestialBody'); expect(r.info.returnType.code).toBe(8); // STRING }); it('returns not-found for an unknown procedure', () => { const cache = new ServiceCache(SAMPLE_RAW); expect(cache.lookup('SpaceCenter', 'GetNothing').found).toBe(false); expect(cache.lookup('Nope', 'X').found).toBe(false); }); it('returns not-found for an unknown service', () => { const cache = new ServiceCache(SAMPLE_RAW); expect(cache.lookup('KerbalAlarmClock', 'GetAlarms').found).toBe(false); }); it('records returnIsNullable for nullable CLASS returns', () => { const cache = new ServiceCache(SAMPLE_RAW); const r = cache.lookup('SpaceCenter', 'CelestialBody.GetParent'); expect(r.found).toBe(true); if (!r.found) throw new Error('unreachable'); expect(r.info.returnIsNullable).toBe(true); expect(r.info.returnType.code).toBe(100); expect(r.info.returnType.name).toBe('CelestialBody'); }); it('resolves enum values by name', () => { const cache = new ServiceCache(SAMPLE_RAW); expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Ship')).toBe(0); expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Station')).toBe(1); expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Probe')).toBe(3); expect(cache.getEnumValue('SpaceCenter', 'VesselSituation', 'Orbiting')).toBe(1); }); it('throws for unknown enum or enum value', () => { const cache = new ServiceCache(SAMPLE_RAW); expect(() => cache.getEnumValue('SpaceCenter', 'NoSuch', 'X')).toThrow(/unknown enum/); expect(() => cache.getEnumValue('SpaceCenter', 'VesselType', 'Hovercraft')).toThrow( /unknown value/, ); }); it('resolves enum value names by code', () => { const cache = new ServiceCache(SAMPLE_RAW); expect(cache.getEnumName('SpaceCenter', 'VesselType', 0)).toBe('Ship'); expect(cache.getEnumName('SpaceCenter', 'VesselType', 3)).toBe('Probe'); expect(cache.getEnumName('SpaceCenter', 'VesselType', 999)).toBeNull(); }); it('lists enum value names', () => { const cache = new ServiceCache(SAMPLE_RAW); const names = cache.getEnumNames('SpaceCenter', 'VesselType'); expect(names).toEqual(['Debris', 'Probe', 'Ship', 'Station']); // sorted }); it('lists procedures in a service', () => { const cache = new ServiceCache(SAMPLE_RAW); const procs = cache.proceduresInService('SpaceCenter'); expect(procs).toContain('SpaceCenter.GetUT'); expect(procs).toContain('SpaceCenter.CelestialBody.GetName'); expect(procs).toContain('SpaceCenter.CelestialBody.GetParent'); }); it('preserves nested list type info', () => { const cache = new ServiceCache(SAMPLE_RAW); const r = cache.lookup('SpaceCenter', 'GetBodies'); expect(r.found).toBe(true); if (!r.found) throw new Error('unreachable'); expect(r.info.returnType.code).toBe(301); // LIST expect(r.info.returnType.types).toHaveLength(1); expect(r.info.returnType.types[0]?.code).toBe(100); expect(r.info.returnType.types[0]?.name).toBe('CelestialBody'); }); it('handles procedures with no return type (returnType is null)', () => { // Real kRPC server omits the `return_type` field for void // procedures (e.g. AddStream, setters). protobufjs decodes // missing message fields as null. Our cache must not crash. const raw = { services: [ { name: 'KRPC', procedures: [ { name: 'AddStream', parameters: [], returnType: null, // <-- the trigger returnIsNullable: false, }, ], classes: [], enumerations: [], }, ], }; const cache = new ServiceCache(raw as unknown as Parameters[0]); const r = cache.lookup('KRPC', 'AddStream'); expect(r.found).toBe(true); if (!r.found) throw new Error('unreachable'); // Missing return type becomes NONE (code 0). expect(r.info.returnType.code).toBe(0); }); it('falls back to .NET-style getter/setter naming for C# properties', () => { // The kRPC server exposes C# properties using .NET accessor // conventions: a property `UT` on SpaceCenter becomes two // procedures named `get_UT` and `set_UT`. User code typically // writes `SpaceCenter.GetUT()` (PascalCase). The cache must // transparently translate to the wire-format name. const raw = { services: [ { name: 'SpaceCenter', procedures: [ { name: 'get_UT', parameters: [], returnType: null, returnIsNullable: false }, { name: 'set_UT', parameters: [], returnType: null, returnIsNullable: false }, { name: 'get_ActiveVessel', parameters: [], returnType: null, returnIsNullable: true }, { name: 'CelestialBody.get_Name', parameters: [], returnType: null, returnIsNullable: false }, ], classes: [], enumerations: [], }, ], }; const cache = new ServiceCache(raw as unknown as Parameters[0]); // Top-level PascalCase -> .NET getter expect(cache.lookup('SpaceCenter', 'GetUT').found).toBe(true); // Top-level PascalCase -> .NET setter expect(cache.lookup('SpaceCenter', 'SetUT').found).toBe(true); expect(cache.lookup('SpaceCenter', 'GetActiveVessel').found).toBe(true); // Class-prefixed: "CelestialBody.GetName" -> "CelestialBody.get_Name" expect(cache.lookup('SpaceCenter', 'CelestialBody.GetName').found).toBe(true); // Exact match still works expect(cache.lookup('SpaceCenter', 'get_UT').found).toBe(true); // And unknown names still return not-found expect(cache.lookup('SpaceCenter', 'NoSuchProcedure').found).toBe(false); }); });