perf(library): send the track list as a dictionary-encoded column table

GetTracks answered with one JSON object per track: 20.5 MB at 26k
tracks, ~350 bytes a row of repeated key names, four cover URLs that
are identical across an album, and the album, artist and genre strings
repeated on every track. Encoding it was ~170 MB of transient Go
allocation, and parsing it the WebView's memory peak.

GetTrackTable replaces it: one array per column, every repeated
string stored once and sent as an index, genre lists interned, and only
the fields the Tracks view reads. LastPlayed and the three larger cover
tiers are left to the details dialog, which now fetches whole tracks by
path. Each row still goes through trackFromRow, so this is an encoding
of the one projection, not a second one. 167 bytes a track against 929
in the test library; a Go test holds it under a quarter of the object
encoding, and a Vitest test reads the Go columns and the generated
Track interface and fails if they drift.

An empty library is now an empty table rather than the "no tracks in
library" error the old binding returned.

Closes #281
This commit is contained in:
yonlu committed 2026-10-05 23:30:37 -04:00
1 parent 4a9fe1d207
commit 7a1412383f
42 files changed
+1011 -275

No files matched your search

@@ -19,6 +19,7 @@ import '@components/cover-grid/cover-grid';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
import { trackTable } from '@test/support/track-table';
const LONG =
'The Rise and Fall of a Midwest Princess in the Key of Everything';
@@ -49,7 +50,7 @@ describe('the album card’s year', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
emit(Events.LibraryScanComplete);
});
@@ -22,6 +22,7 @@ import '@components/cover-grid/cover-grid';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { trackTable } from '@test/support/track-table';
/**
* Enough albums to fill more than one row.
@@ -82,7 +83,7 @@ describe('the album dropdown', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
stub('library.Library.GetAlbumTracks', TRACKS);
stub('library.Library.GetAlbumTracks', TRACKS);
emit(Events.LibraryScanComplete);
@@ -155,7 +156,7 @@ describe('the albums grid scrolls', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
emit(Events.LibraryScanComplete);
});
+6 -5
View File
@@ -20,6 +20,7 @@ import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { searchStore } from '@store/search-store';
import { trackTable } from '@test/support/track-table';
/**
* The searchable columns' accessors read these fields and call
@@ -76,7 +77,7 @@ describe('the track list says how it is sorted', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -132,7 +133,7 @@ describe('the track list has a voice for its own state', () => {
});
it('announces the result of a search that matches nothing', async () => {
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
emit(Events.LibraryScanComplete);
const el = await fixture<LitElement>('track-list');
@@ -163,7 +164,7 @@ describe('a selectable grid is a listbox, not a row of buttons', () => {
searchStore.setTerm('');
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetGenres', GENRES);
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -197,7 +198,7 @@ describe('a clipped value is readable somewhere', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -240,7 +241,7 @@ describe('the playing row is more than a colour', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
stub('library.Library.GetAlbums', []);
emit(Events.LibraryScanComplete);
});
@@ -35,6 +35,7 @@ import {
imagePrefetched,
resetImagePrefetch,
} from '@utils/image-prefetch';
import { trackTable } from '@test/support/track-table';
/** Enough albums that the virtualizer's own window is nowhere near the end. */
const ALBUMS = Array.from({ length: 400 }, (_, i) => {
@@ -118,7 +119,7 @@ beforeEach(() => {
localStorage.clear();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
stub('library.Library.GetGenres', []);
emit(Events.LibraryScanComplete);
});
@@ -29,6 +29,7 @@ import '@components/genres-view/genres-view';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
import { trackTable } from '@test/support/track-table';
const ARTISTS = [
{ ID: 1, Name: 'Alpha', AlbumCount: 2, TrackCount: 9 },
@@ -58,7 +59,7 @@ describe('a card grid shows its selection', () => {
resetHarness();
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetGenres', GENRES);
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
stub('library.Library.GetAlbums', []);
// The views read through LibraryController, whose cache is only
// primed by a scan-complete; without it they render nothing and the
+1 -1
View File
@@ -191,7 +191,7 @@ describe('<library-filter>', () => {
select?.dispatchEvent(new Event('change'));
await flush();
expect(lastArgs('library.Library.GetTracks')).toEqual([8]);
expect(lastArgs('library.Library.GetTrackTable')).toEqual([8]);
});
it('picks up a library added while it was on screen', async () => {
@@ -72,7 +72,7 @@ function boxOf(el: Element | null | undefined): { w: number; h: number } {
describe('the way out of a detail view', () => {
beforeEach(() => {
for (const path of [
'library.Library.GetTracks',
'library.Library.GetTrackTable',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
@@ -11,11 +11,12 @@ import '@components/track-list/track-list';
import { Events } from '../../src/events';
import { emit, stub, stubFailure, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, text } from '@test/support/render';
import { trackTable } from '@test/support/track-table';
/** Drop the library store's cache so the list has to fetch. */
async function emptyLibrary(): Promise<void> {
resetHarness();
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
stub('library.Library.GetAlbums', []);
stub('library.Library.GetArtists', []);
stub('library.Library.GetGenres', []);
@@ -40,7 +41,7 @@ describe('<track-list> empty, loading and failed', () => {
});
it('says the query failed, and offers to try again', async () => {
stubFailure('library.Library.GetTracks', 'sql: database is locked');
stubFailure('library.Library.GetTrackTable', 'sql: database is locked');
emit(Events.LibraryScanComplete);
await flush();
@@ -14,6 +14,7 @@ import '@components/track-list/track-list';
import { stub, emit, flush } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { trackTable } from '@test/support/track-table';
/** Two fixture tracks, enough to move a focus ring between. */
const TRACKS = [
@@ -78,7 +79,7 @@ describe('<queue-panel> when closed', () => {
describe('<track-list> roving tabindex', () => {
beforeEach(() => {
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
});
it('offers exactly one tab stop, however many rows there are', async () => {
@@ -31,6 +31,7 @@ import {
stubFailure,
} from '@test/support/harness';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { trackTable } from '@test/support/track-table';
const SEARCH = 'explore.Service.SearchLocal';
@@ -133,7 +134,7 @@ describe('<explore-view> badges', () => {
stub('explore.Service.GetArtistImageURL', '');
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
stub('library.Library.GetAlbums', []);
stub('library.Library.GetTracks', []);
stub('library.Library.GetTrackTable', trackTable([]));
await withRequests([]);
});
@@ -48,21 +48,12 @@ describe('the track list Art column', () => {
]).toEqual(['lazy', 'async']);
});
it('falls back through the tiers rather than rendering nothing', () => {
const onlyOriginal = cell('albumArt', {
CoverArtPath: '/covers/abc.jpg',
CoverArtSmall: '',
CoverArtMedium: '',
}).querySelector('img');
expect(onlyOriginal?.getAttribute('src')).toBe('/covers/abc.jpg');
});
// There is no fallback through the larger tiers any more: the list
// carries only the small one (#281), and the backend derives every
// tier from the same file, so it is set whenever any of them is.
it('renders nothing at all when there is no art', () => {
const none = cell('albumArt', {
CoverArtPath: '',
CoverArtSmall: '',
CoverArtMedium: '',
});
expect(none.querySelector('img')).toBeNull();
+1 -1
View File
@@ -117,7 +117,7 @@ const TAGS = [
*/
function stubEmptyBackend(): void {
const emptyLists = [
'library.Library.GetTracks',
'library.Library.GetTrackTable',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
@@ -24,6 +24,7 @@ import '@components/selection-bar/selection-bar';
import { calls, flush, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
import { trackTable } from '@test/support/track-table';
const HELD = LONG_PRESS_MS + 120;
@@ -117,7 +118,7 @@ function rows(el: HTMLElement): HTMLElement[] {
describe('a finger on a track row', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.SetQueue', null);
@@ -320,7 +321,7 @@ describe('<selection-bar>', () => {
describe('a tap on a name inside a row', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.SetQueue', null);
+2 -1
View File
@@ -33,6 +33,7 @@ import '@components/track-list/track-list';
import { calls, flush, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { installTouchGestures } from '@utils/touch-gestures';
import { trackTable } from '@test/support/track-table';
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -125,7 +126,7 @@ function threshold(row: HTMLElement): number {
describe('a finger swiped right across a track row', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.SetQueue', null);
@@ -231,7 +231,7 @@ const CACHED_VIEWS = [
* binding resolves undefined, which is not what Go sends. */
function stubEmptyBackend(): void {
for (const path of [
'library.Library.GetTracks',
'library.Library.GetTrackTable',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
+1 -1
View File
@@ -35,7 +35,7 @@ const importTimeDefaults: Array<[string, unknown]> = [
// libraryStore and playlistStore fetch eagerly at import. Left
// unstubbed they would cache `undefined` — not the empty list Go
// sends — and every consumer would then crash on `.length`.
['library.Library.GetTracks', []],
['library.Library.GetTrackTable', { strings: [''] }],
['library.Library.GetAlbums', []],
['library.Library.GetArtists', []],
['library.Library.GetGenres', []],
+19 -20
View File
@@ -19,10 +19,11 @@ import {
lastArgs,
resetHarness,
} from '@test/support/harness';
import { trackTable } from '@test/support/track-table';
const TRACKS = [
{ ID: 1, Title: 'One', FilePath: '/a.mp3', PlayCount: 0, LastPlayed: '' },
{ ID: 2, Title: 'Two', FilePath: '/b.mp3', PlayCount: 4, LastPlayed: 'x' },
{ TrackName: 'One', FilePath: '/a.mp3', PlayCount: 0 },
{ TrackName: 'Two', FilePath: '/b.mp3', PlayCount: 4 },
];
const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }];
@@ -33,15 +34,10 @@ const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }];
/** Stub every read binding the store can reach. Unstubbed bindings
* resolve undefined, which the store would cache as if it were data. */
function stubReads(): void {
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetTrackTable', trackTable(TRACKS));
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetGenres', GENRES);
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetGenres', GENRES);
stub('library.Library.GetAlbumsByArtist', ALBUMS);
stub('library.Library.GetAlbumsByArtist', ALBUMS);
stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES);
}
@@ -69,7 +65,7 @@ describe('library store: caching', () => {
it('serves a second read from cache without touching the backend', async () => {
await libraryStore.getTracks();
expect(calls('library.Library.GetTracks')).toHaveLength(0);
expect(calls('library.Library.GetTrackTable')).toHaveLength(0);
});
it('deduplicates concurrent first reads into one backend call', async () => {
@@ -88,11 +84,11 @@ describe('library store: caching', () => {
it('exposes cached collections synchronously once loaded', () => {
expect([
libraryStore.getCachedTracks(),
libraryStore.getCachedTracks()?.map((t) => t.FilePath),
libraryStore.getCachedAlbums(),
libraryStore.cachedArtists,
libraryStore.getCachedGenres(),
]).toEqual([TRACKS, ALBUMS, ARTISTS, GENRES]);
]).toEqual([['/a.mp3', '/b.mp3'], ALBUMS, ARTISTS, GENRES]);
});
it('refetches everything when a scan completes', async () => {
@@ -103,7 +99,7 @@ describe('library store: caching', () => {
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
'library.Library.GetTracks',
'library.Library.GetTrackTable',
]);
});
@@ -111,7 +107,7 @@ describe('library store: caching', () => {
emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' });
await flush();
expect(calls('library.Library.GetTracks')).toHaveLength(1);
expect(calls('library.Library.GetTrackTable')).toHaveLength(1);
});
/*
@@ -140,10 +136,11 @@ describe('library store: caching', () => {
it('patches the one track it names', () => {
const tracks = libraryStore.getCachedTracks();
// LastPlayed is not on the list's rows (#281); trackCache
// patches it on whole tracks.
expect(tracks?.[0]).toMatchObject({
FilePath: '/a.mp3',
PlayCount: 9,
LastPlayed: '2026-08-11 10:00:00',
});
});
@@ -192,7 +189,7 @@ describe('library store: caching', () => {
});
it('does not refetch the tracks', () => {
expect(calls('library.Library.GetTracks')).toHaveLength(0);
expect(calls('library.Library.GetTrackTable')).toHaveLength(0);
});
it('splices the removed track out in place', () => {
@@ -258,7 +255,7 @@ describe('library store: library filter', () => {
libraryStore.setSelectedLibrary(7);
await flush();
expect(lastArgs('library.Library.GetTracks')).toEqual([7]);
expect(lastArgs('library.Library.GetTrackTable')).toEqual([7]);
});
it('ignores a redundant selection instead of invalidating', async () => {
@@ -304,12 +301,12 @@ describe('library store: a fetch that is overtaken', () => {
it('serves the library that is selected, not the one that was in flight', async () => {
const pending: Array<{ id: number; resolve: (v: unknown) => void }> = [];
const byLibrary = (id: number) => [{ ID: id, Title: `Library ${id}` }];
const byLibrary = (id: number) => trackTable([{ FilePath: `/lib-${id}.mp3` }]);
// Only the track fetch is held open; the other three settle at once,
// so the test is about the overtaking and nothing else.
stub(
'library.Library.GetTracks',
'library.Library.GetTrackTable',
(id: number) =>
new Promise((resolve) => {
pending.push({ id, resolve });
@@ -327,11 +324,13 @@ describe('library store: a fetch that is overtaken', () => {
pending.find((p) => p.id === 8)?.resolve(byLibrary(8));
await flush();
expect(libraryStore.getCachedTracks()).toEqual(byLibrary(8));
expect(libraryStore.getCachedTracks()?.map((t) => t.FilePath)).toEqual([
'/lib-8.mp3',
]);
});
it('settles the waiters when the fetch they are waiting on fails', async () => {
stubFailure('library.Library.GetTracks', 'sql: database is locked');
stubFailure('library.Library.GetTrackTable', 'sql: database is locked');
// Invalidation drops the cache and starts the fetch that fails.
emit(Events.LibraryScanComplete);
+86
View File
@@ -0,0 +1,86 @@
/**
* Encode test tracks as the backend's `TrackTable` (#281), so a stub of
* `library.Library.GetTrackTable` can be written as a list of tracks.
*
* Test fixtures are partial tracks; a missing field encodes as the
* zero value Go would have sent. The encoding mirrors
* `backend/library/tracktable.go` closely enough to exercise the real
* decoder: shared string table with "" at 0, genre lists interned.
*/
import type * as library from '@go/library/models.js';
/** A fixture: any subset of a track's fields, loosely typed as fixtures are. */
type PartialTrack = { FilePath: string } & Record<string, unknown>;
const STRING_COLS = [
['trackName', 'TrackName'],
['artistName', 'ArtistName'],
['album', 'Album'],
['composer', 'Composer'],
['fileType', 'FileType'],
['artistMbid', 'ArtistMBID'],
['releaseGroupMbid', 'ReleaseGroupMBID'],
['recordingMbid', 'RecordingMBID'],
['coverArtSmall', 'CoverArtSmall'],
] as const;
const INT_COLS = [
['trackNumber', 'TrackNumber'],
['discNumber', 'DiscNumber'],
['year', 'Year'],
['sampleRate', 'SampleRate'],
['bitDepth', 'BitDepth'],
['channels', 'Channels'],
['bitrate', 'Bitrate'],
['fileSize', 'FileSize'],
['playCount', 'PlayCount'],
] as const;
export function trackTable(tracks: readonly PartialTrack[]): library.TrackTable {
const strings = [''];
const index = new Map<string, number>([['', 0]]);
const intern = (s: string | undefined): number => {
const v = s ?? '';
let i = index.get(v);
if (i === undefined) {
i = strings.length;
strings.push(v);
index.set(v, i);
}
return i;
};
const genreSets: number[][] = [];
const genreIndex = new Map<string, number>();
const table: Record<string, unknown> = {
strings,
genreSets,
filePath: tracks.map((t) => t.FilePath),
lengthMs: tracks.map((t) => Number(t['TrackLength'] ?? 0) || 0),
genre: tracks.map((t) => {
const genres = (t['Genre'] as string[] | null | undefined) ?? [];
const key = genres.join('\u0000');
let i = genreIndex.get(key);
if (i === undefined) {
i = genreSets.length;
genreSets.push(genres.map(intern));
genreIndex.set(key, i);
}
return i;
}),
};
for (const [col, field] of STRING_COLS) {
table[col] = tracks.map((t) => intern(t[field] as string | undefined));
}
for (const [col, field] of INT_COLS) {
table[col] = tracks.map((t) => (t[field] as number | undefined) ?? 0);
}
return table as unknown as library.TrackTable;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* `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<string>('../../bindings/yellowjacket/backend/library/models.ts', {
eager: true,
query: '?raw',
import: 'default',
}),
)[0] ?? '';
const ENCODER = Object.values(
import.meta.glob<string>('../../../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<string>(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());
});
});