@ciphore/radiocli 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +53 -3
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +179 -98
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +28 -8
- package/dist/providers/radio-browser.js +141 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +264 -149
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +489 -54
- package/dist/ui/AppContent.js +11 -12
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +10 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +15 -2
- package/dist/ui/screen-items.js +60 -21
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +109 -10
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +44 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +37 -6
- package/dist/ui/use-command-executor.js +34 -1
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/dist/update-check.js +122 -0
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -214
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { appVersion } from './version.js';
|
|
4
|
+
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const UPDATE_PACKAGE_NAME = '@ciphore/radiocli';
|
|
6
|
+
export async function checkForUpdate({ currentVersion = appVersion(), fetchImpl = fetch, now = new Date(), packageName = UPDATE_PACKAGE_NAME, timeoutMs = 3000 } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetchWithTimeout(registryLatestUrl(packageName), fetchImpl, timeoutMs);
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`npm registry returned ${response.status}`);
|
|
11
|
+
}
|
|
12
|
+
const parsed = await response.json();
|
|
13
|
+
const latestVersion = typeof parsed.version === 'string' ? parsed.version : undefined;
|
|
14
|
+
if (!latestVersion) {
|
|
15
|
+
throw new Error('npm registry response did not include a version');
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
checkedAt: now.toISOString(),
|
|
19
|
+
currentVersion,
|
|
20
|
+
latestVersion,
|
|
21
|
+
updateAvailable: compareSemver(latestVersion, currentVersion) > 0
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return {
|
|
26
|
+
checkedAt: now.toISOString(),
|
|
27
|
+
currentVersion,
|
|
28
|
+
updateAvailable: false,
|
|
29
|
+
error: error instanceof Error ? error.message : 'Update check failed'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function shouldCheckForUpdate(updateCheck, now = Date.now()) {
|
|
34
|
+
if (process.env.RADIOCLI_DISABLE_UPDATE_CHECK === '1' || process.env.CI === 'true') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (!updateCheck?.checkedAt) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const checkedAt = Date.parse(updateCheck.checkedAt);
|
|
41
|
+
return !Number.isFinite(checkedAt) || now - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
|
|
42
|
+
}
|
|
43
|
+
export function updateStatusText(updateCheck) {
|
|
44
|
+
if (!updateCheck) {
|
|
45
|
+
return 'not checked yet';
|
|
46
|
+
}
|
|
47
|
+
if (updateCheck.updateAvailable && updateCheck.latestVersion) {
|
|
48
|
+
return `v${updateCheck.latestVersion} available`;
|
|
49
|
+
}
|
|
50
|
+
if (updateCheck.error) {
|
|
51
|
+
return `check failed: ${updateCheck.error}`;
|
|
52
|
+
}
|
|
53
|
+
return updateCheck.latestVersion ? `current at v${updateCheck.latestVersion}` : 'not checked yet';
|
|
54
|
+
}
|
|
55
|
+
export function updateCommandForInstall(entryPath = process.argv[1]) {
|
|
56
|
+
const resolved = resolvePath(entryPath);
|
|
57
|
+
const haystack = [entryPath, resolved].filter(Boolean).join('\n');
|
|
58
|
+
if (/\/(?:opt\/homebrew|usr\/local)\/(?:Cellar|Homebrew)\//.test(haystack) || /\/\.linuxbrew\/(?:Cellar|Homebrew)\//.test(haystack)) {
|
|
59
|
+
return { method: 'homebrew', command: 'brew update && brew upgrade radiocli' };
|
|
60
|
+
}
|
|
61
|
+
if (/\/node_modules\/@ciphore\/radiocli\//.test(haystack)) {
|
|
62
|
+
return { method: 'npm', command: 'npm install -g @ciphore/radiocli@latest' };
|
|
63
|
+
}
|
|
64
|
+
return { method: 'unknown', command: 'npm install -g @ciphore/radiocli@latest' };
|
|
65
|
+
}
|
|
66
|
+
export function installUpdate(command = updateCommandForInstall().command) {
|
|
67
|
+
return new Promise(resolve => {
|
|
68
|
+
const shell = process.platform === 'win32' ? 'cmd.exe' : 'sh';
|
|
69
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
|
|
70
|
+
const child = spawn(shell, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
71
|
+
const chunks = [];
|
|
72
|
+
child.stdout.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
73
|
+
child.stderr.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
74
|
+
child.on('error', error => {
|
|
75
|
+
resolve({ ok: false, command, output: error.message });
|
|
76
|
+
});
|
|
77
|
+
child.on('close', code => {
|
|
78
|
+
const output = Buffer.concat(chunks).toString('utf8').trim();
|
|
79
|
+
resolve({ ok: code === 0, command, output });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function compareSemver(left, right) {
|
|
84
|
+
const leftParts = semverParts(left);
|
|
85
|
+
const rightParts = semverParts(right);
|
|
86
|
+
for (let index = 0; index < 3; index += 1) {
|
|
87
|
+
const delta = leftParts[index] - rightParts[index];
|
|
88
|
+
if (delta !== 0) {
|
|
89
|
+
return delta;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
function registryLatestUrl(packageName) {
|
|
95
|
+
return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
|
|
96
|
+
}
|
|
97
|
+
async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
|
+
try {
|
|
101
|
+
return await fetchImpl(url, { signal: controller.signal });
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function semverParts(version) {
|
|
108
|
+
const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? '';
|
|
109
|
+
const [major = '0', minor = '0', patch = '0'] = normalized.split('.');
|
|
110
|
+
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
111
|
+
}
|
|
112
|
+
function resolvePath(path) {
|
|
113
|
+
if (!path) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return realpathSync(path);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return path;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -31,3 +31,10 @@ SOFTWARE.
|
|
|
31
31
|
|
|
32
32
|
The land polygon data is derived from Natural Earth 110m land data. Natural
|
|
33
33
|
Earth vector data is public domain.
|
|
34
|
+
|
|
35
|
+
## node-airtunes2
|
|
36
|
+
|
|
37
|
+
AirPlay support is provided as an optional integration with `node-airtunes2`
|
|
38
|
+
2.5.0, distributed under the GNU Affero General Public License v3. Its source,
|
|
39
|
+
license text, and attribution are available in the installed dependency and at
|
|
40
|
+
<https://github.com/bertrandda/node-airtunes2>.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciphore/radiocli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"protobufjs": "^7.5.8"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@types/node": "^
|
|
76
|
+
"@types/node": "^22.15.0",
|
|
77
77
|
"@types/react": "^19.2.15",
|
|
78
78
|
"ink-testing-library": "^4.0.0",
|
|
79
79
|
"knip": "^6.14.2",
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { render } from 'ink-testing-library';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { DisplayContext, resolveDisplayMode } from '../display-context.js';
|
|
5
|
-
import { HelpScreen } from './HelpScreen.js';
|
|
6
|
-
import { NowPlayingScreen } from './NowPlayingScreen.js';
|
|
7
|
-
import { SettingsScreen } from './SettingsScreen.js';
|
|
8
|
-
import { ExploreScreen } from './ExploreScreen.js';
|
|
9
|
-
import { CountriesScreen } from './CountriesScreen.js';
|
|
10
|
-
import { buildContributionGraph, contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
|
|
11
|
-
import { settingsItems } from '../screen-items.js';
|
|
12
|
-
import { defaultExploreCursor } from '../app-state.js';
|
|
13
|
-
const station = {
|
|
14
|
-
id: 'station-1',
|
|
15
|
-
provider: 'radio-browser',
|
|
16
|
-
name: 'KEXP 90.3 FM',
|
|
17
|
-
country: 'United States',
|
|
18
|
-
tags: ['indie'],
|
|
19
|
-
codec: 'MP3',
|
|
20
|
-
bitrate: 128
|
|
21
|
-
};
|
|
22
|
-
const playback = {
|
|
23
|
-
backend: 'mpv',
|
|
24
|
-
state: 'idle',
|
|
25
|
-
volume: 70,
|
|
26
|
-
muted: false,
|
|
27
|
-
ready: false
|
|
28
|
-
};
|
|
29
|
-
const diagnostics = {
|
|
30
|
-
backend: 'mpv',
|
|
31
|
-
availableBackends: ['mpv'],
|
|
32
|
-
preferredBackend: 'auto',
|
|
33
|
-
active: false,
|
|
34
|
-
volume: 70,
|
|
35
|
-
muted: false,
|
|
36
|
-
ready: false
|
|
37
|
-
};
|
|
38
|
-
const trackHistory = [
|
|
39
|
-
{ title: 'Bjork - Joga', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '2' },
|
|
40
|
-
{ title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '1' }
|
|
41
|
-
];
|
|
42
|
-
function renderNowPlaying(asciiMode, showDiagnostics) {
|
|
43
|
-
const mode = resolveDisplayMode({ asciiMode }, {});
|
|
44
|
-
return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(NowPlayingScreen, { station: station, playback: playback, metadata: null, theme: "green", favorite: true, pulse: 0, diagnostics: diagnostics, sleepLabel: "Sleep off", showDiagnostics: showDiagnostics, stationTime: "12:00", receiverStyle: "pulse-grid", trackHistory: trackHistory, width: 72, height: 30 }) }));
|
|
45
|
-
}
|
|
46
|
-
const settings = {
|
|
47
|
-
theme: 'green',
|
|
48
|
-
receiverStyle: 'pulse-grid',
|
|
49
|
-
volume: 70,
|
|
50
|
-
enableRadioGarden: false,
|
|
51
|
-
enableNearbyLocation: false,
|
|
52
|
-
preferredBackend: 'auto',
|
|
53
|
-
tuneTimeoutSeconds: 12,
|
|
54
|
-
skipBrokenStreams: true,
|
|
55
|
-
mediaKeys: { previous: [], playPause: [], next: [] },
|
|
56
|
-
resumeOnLaunch: true,
|
|
57
|
-
asciiMode: true,
|
|
58
|
-
reduceMotion: false,
|
|
59
|
-
transparentBackground: false
|
|
60
|
-
};
|
|
61
|
-
describe('SettingsScreen rendering', () => {
|
|
62
|
-
it('renders the new display and playback toggles with their values', () => {
|
|
63
|
-
const settingsIndex = Math.max(0, settingsItems.indexOf('Resume last station on launch'));
|
|
64
|
-
const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
|
|
65
|
-
const frame = lastFrame() ?? '';
|
|
66
|
-
expect(frame).toContain('Resume last station on launch');
|
|
67
|
-
expect(frame).toContain('ASCII-safe display');
|
|
68
|
-
expect(frame).toContain('Reduce motion');
|
|
69
|
-
expect(frame).toContain('Transparent background');
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
function renderExplore(asciiMode) {
|
|
73
|
-
const mode = resolveDisplayMode({ asciiMode }, {});
|
|
74
|
-
return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(ExploreScreen, { title: "Explore", subtitle: "Move a map cursor through geotagged stations", stations: [station], selected: 0, loading: false, theme: "green", favorites: new Set(), filterLabel: "", cursor: defaultExploreCursor, pageSize: 8, width: 100, height: 24 }) }));
|
|
75
|
-
}
|
|
76
|
-
describe('Explore world map rendering', () => {
|
|
77
|
-
it('rasterizes land with braille glyphs by default', () => {
|
|
78
|
-
const frame = renderExplore(false).lastFrame() ?? '';
|
|
79
|
-
expect(/[⠀-⣿]/.test(frame)).toBe(true);
|
|
80
|
-
});
|
|
81
|
-
it('replaces braille with ASCII in ASCII-safe mode', () => {
|
|
82
|
-
const frame = renderExplore(true).lastFrame() ?? '';
|
|
83
|
-
expect(/[⠀-⣿]/.test(frame)).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
describe('CountriesScreen rendering', () => {
|
|
87
|
-
it('keeps long country rows to one terminal line', () => {
|
|
88
|
-
const frame = render(_jsx(CountriesScreen, { countries: [
|
|
89
|
-
{
|
|
90
|
-
name: 'The Extremely Long Democratic Republic Of The Country With A Very Long Name',
|
|
91
|
-
code: 'TL',
|
|
92
|
-
stationCount: 123456789
|
|
93
|
-
}
|
|
94
|
-
], selected: 0, loading: false, filter: "", editingFilter: false, theme: "green", pageSize: 1, width: 48 })).lastFrame() ?? '';
|
|
95
|
-
expect(frame).toContain('>');
|
|
96
|
-
expect(frame).toContain('…');
|
|
97
|
-
expect(frame).not.toContain('Very Long Name');
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
describe('StatsScreen rendering', () => {
|
|
101
|
-
it('renders activity heatmap as large calendar-year cells with readable month labels', () => {
|
|
102
|
-
const library = {
|
|
103
|
-
recent: [],
|
|
104
|
-
favorites: [],
|
|
105
|
-
imported: [],
|
|
106
|
-
trackHistory: [],
|
|
107
|
-
searchHistory: [],
|
|
108
|
-
activity: {
|
|
109
|
-
sessions: [
|
|
110
|
-
{
|
|
111
|
-
id: 'listen',
|
|
112
|
-
station,
|
|
113
|
-
startedAt: new Date(2026, 4, 24, 12).toISOString(),
|
|
114
|
-
endedAt: new Date(2026, 4, 24, 13).toISOString(),
|
|
115
|
-
listenedSeconds: 3600
|
|
116
|
-
}
|
|
117
|
-
]
|
|
118
|
-
},
|
|
119
|
-
settings
|
|
120
|
-
};
|
|
121
|
-
const frame = render(_jsx(DisplayContext.Provider, { value: resolveDisplayMode({ asciiMode: false }, {}), children: _jsx(StatsScreen, { library: library, theme: "green", width: 132, height: 32 }) })).lastFrame() ?? '';
|
|
122
|
-
expect(frame).toContain('Jan');
|
|
123
|
-
expect(frame).not.toContain('██');
|
|
124
|
-
});
|
|
125
|
-
it('starts the contribution graph at January and uses large cells when space allows', () => {
|
|
126
|
-
const graph = buildContributionGraph([
|
|
127
|
-
{ date: '2026-01-01', seconds: 3600 },
|
|
128
|
-
{ date: '2026-12-31', seconds: 0 }
|
|
129
|
-
], 170);
|
|
130
|
-
expect(graph.months.startsWith('Jan')).toBe(true);
|
|
131
|
-
expect(graph.months).toContain('Dec');
|
|
132
|
-
expect(graph.cellText).toBe(' ');
|
|
133
|
-
expect(graph.cellGap).toBe('');
|
|
134
|
-
expect(graph.tileHeight).toBe(2);
|
|
135
|
-
expect(graph.rows[4]?.cells[0]).toMatchObject({
|
|
136
|
-
level: 4,
|
|
137
|
-
text: ' ',
|
|
138
|
-
visible: true
|
|
139
|
-
});
|
|
140
|
-
expect(graph.rows[0]?.cells[0]).toMatchObject({
|
|
141
|
-
level: 0,
|
|
142
|
-
text: ' ',
|
|
143
|
-
visible: true
|
|
144
|
-
});
|
|
145
|
-
expect(graph.rows[6]?.cells.at(-1)).toMatchObject({
|
|
146
|
-
level: 0,
|
|
147
|
-
text: ' ',
|
|
148
|
-
visible: true
|
|
149
|
-
});
|
|
150
|
-
const normalWidthGraph = buildContributionGraph([
|
|
151
|
-
{ date: '2026-01-01', seconds: 3600 },
|
|
152
|
-
{ date: '2026-12-31', seconds: 0 }
|
|
153
|
-
], 128);
|
|
154
|
-
expect(normalWidthGraph.cellText).toBe(' ');
|
|
155
|
-
expect(normalWidthGraph.cellGap).toBe('');
|
|
156
|
-
const compactWidthGraph = buildContributionGraph([
|
|
157
|
-
{ date: '2026-01-01', seconds: 3600 },
|
|
158
|
-
{ date: '2026-12-31', seconds: 0 }
|
|
159
|
-
], 80);
|
|
160
|
-
expect(compactWidthGraph.cellText).toBe(' ');
|
|
161
|
-
expect(compactWidthGraph.cellGap).toBe('');
|
|
162
|
-
});
|
|
163
|
-
it('uses one-line heatmap cells when the stats panel is height constrained', () => {
|
|
164
|
-
const graph = buildContributionGraph([
|
|
165
|
-
{ date: '2026-01-01', seconds: 3600 },
|
|
166
|
-
{ date: '2026-12-31', seconds: 0 }
|
|
167
|
-
], 170, 20);
|
|
168
|
-
expect(graph.cellText).toBe(' ');
|
|
169
|
-
expect(graph.cellGap).toBe('');
|
|
170
|
-
expect(graph.tileHeight).toBe(1);
|
|
171
|
-
});
|
|
172
|
-
it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
|
|
173
|
-
const days = [
|
|
174
|
-
...Array.from({ length: 19 }, (_, index) => ({ date: `2026-05-${String(index + 1).padStart(2, '0')}`, seconds: 3600 })),
|
|
175
|
-
{ date: '2026-05-20', seconds: 36_000 }
|
|
176
|
-
];
|
|
177
|
-
const scaleSeconds = contributionScaleSeconds(days);
|
|
178
|
-
expect(scaleSeconds).toBe(3600);
|
|
179
|
-
expect(contributionLevel(3600, scaleSeconds)).toBe(4);
|
|
180
|
-
expect(contributionLevel(900, scaleSeconds)).toBe(2);
|
|
181
|
-
expect(contributionLevel(0, scaleSeconds)).toBe(0);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
describe('HelpScreen rendering', () => {
|
|
185
|
-
it('lists keybinding sections and : commands', () => {
|
|
186
|
-
const { lastFrame } = render(_jsx(HelpScreen, { theme: "green", width: 80 }));
|
|
187
|
-
const frame = lastFrame() ?? '';
|
|
188
|
-
expect(frame).toContain('Navigation');
|
|
189
|
-
expect(frame).toContain('Playback');
|
|
190
|
-
expect(frame).toContain(':search');
|
|
191
|
-
expect(frame).toContain(':doctor');
|
|
192
|
-
expect(frame).toContain('Toggle this help');
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
describe('NowPlayingScreen rendering', () => {
|
|
196
|
-
it('shows recent tracks for the tuned station when diagnostics are open', () => {
|
|
197
|
-
const { lastFrame } = renderNowPlaying(false, true);
|
|
198
|
-
const frame = lastFrame() ?? '';
|
|
199
|
-
expect(frame).toContain('Bjork - Joga');
|
|
200
|
-
expect(frame).toContain('UNITED STATES');
|
|
201
|
-
});
|
|
202
|
-
it('uses Unicode box borders by default', () => {
|
|
203
|
-
const { lastFrame } = renderNowPlaying(false, false);
|
|
204
|
-
const frame = lastFrame() ?? '';
|
|
205
|
-
expect(/[\u2500-\u257f]/.test(frame)).toBe(true);
|
|
206
|
-
});
|
|
207
|
-
it('emits only ASCII characters in ASCII-safe mode', () => {
|
|
208
|
-
const { lastFrame } = renderNowPlaying(true, true);
|
|
209
|
-
const frame = lastFrame() ?? '';
|
|
210
|
-
expect(frame).toContain('Bjork - Joga');
|
|
211
|
-
// No braille, block, box-drawing, or punctuation glyphs survive ASCII mode.
|
|
212
|
-
expect(/[^\x00-\x7f]/.test(frame)).toBe(false);
|
|
213
|
-
});
|
|
214
|
-
});
|