/** * `decodeTrackTable` is the only reader of the backend's columnar track * list (#281). The encoder is Go and the row type is TypeScript, so the * last block reads both sides' declarations and fails if a column is * added on one and not the other. */ import { describe, expect, it } from 'vitest'; import { decodeTrackTable, LIST_TRACK_OMITS, TrackTableError, } from '@utils/track-table'; import { trackTable } from '@test/support/track-table'; const FIXTURE = [ { FilePath: '/m/a/1.flac', TrackName: 'One', ArtistName: 'Artist', Album: 'Album', Genre: ['Ambient', 'Drone'], TrackLength: '215000', TrackNumber: 1, CoverArtSmall: '/covers/x_sm.jpg', PlayCount: 3, }, { FilePath: '/m/a/2.flac', TrackName: 'Two', ArtistName: 'Artist', Album: 'Album', Genre: ['Ambient', 'Drone'], TrackLength: '1000', TrackNumber: 2, CoverArtSmall: '/covers/x_sm.jpg', }, ]; describe('decodeTrackTable', () => { it('gives back each track it was given', () => { const [a, b] = decodeTrackTable(trackTable(FIXTURE)); expect(a).toMatchObject({ FilePath: '/m/a/1.flac', TrackName: 'One', Album: 'Album', Genre: ['Ambient', 'Drone'], TrackLength: '215000', TrackNumber: 1, PlayCount: 3, Composer: '', }); expect(b).toMatchObject({ TrackName: 'Two', TrackLength: '1000', PlayCount: 0 }); }); it('shares one genre list between tracks that have the same one', () => { const [a, b] = decodeTrackTable(trackTable(FIXTURE)); expect(a!.Genre).toBe(b!.Genre); }); it('decodes an empty library to no rows', () => { expect(decodeTrackTable(trackTable([]))).toEqual([]); }); it('refuses a table whose columns disagree in length', () => { const broken = trackTable(FIXTURE); (broken as unknown as { album: number[] }).album = [1]; expect(() => decodeTrackTable(broken)).toThrow(TrackTableError); }); it('refuses a string index past the table', () => { const broken = trackTable(FIXTURE); (broken as unknown as { album: number[] }).album = [1, 999]; expect(() => decodeTrackTable(broken)).toThrow(/out of range/); }); }); /** The generated Track interface and the Go encoder, as text. */ const MODELS = Object.values( import.meta.glob('../../bindings/yellowjacket/backend/library/models.ts', { eager: true, query: '?raw', import: 'default', }), )[0] ?? ''; const ENCODER = Object.values( import.meta.glob('../../../backend/library/tracktable.go', { eager: true, query: '?raw', import: 'default', }), )[0] ?? ''; function interfaceKeys(source: string, name: string): string[] { const body = source.split(`export interface ${name} {`)[1]?.split('\n}')[0] ?? ''; return [...body.matchAll(/^\s+"(\w+)"\??:/gm)].map((m) => m[1]!); } describe('the list row and the Go table agree', () => { const trackKeys = interfaceKeys(MODELS, 'Track'); const tableKeys = [...ENCODER.matchAll(/json:"(\w+)"/g)].map((m) => m[1]!); it('read both declarations', () => { expect(trackKeys.length).toBeGreaterThan(20); expect(tableKeys.length).toBeGreaterThan(20); }); it('decodes every Track field the list keeps, and only those', () => { const decoded = Object.keys(decodeTrackTable(trackTable(FIXTURE))[0]!).sort(); const omitted = new Set(LIST_TRACK_OMITS); expect(decoded).toEqual(trackKeys.filter((k) => !omitted.has(k)).sort()); }); it('has a column for every field it decodes', () => { // Columns are the field names in lower camel case, except the two // that are not columns of a field and the length, which is sent as // the number it encodes. const columns = new Set(tableKeys.filter((k) => !['strings', 'genreSets'].includes(k))); const expected = trackKeys .filter((k) => !(LIST_TRACK_OMITS as readonly string[]).includes(k)) .map((k) => (k === 'TrackLength' ? 'lengthMs' : k[0]!.toLowerCase() + k.slice(1))) .map((k) => k.replace(/MBID$/, 'Mbid')); expect([...columns].sort()).toEqual(expected.sort()); }); });