Feature/streamed install prototype #27
+5
-4
@@ -54,11 +54,12 @@ product-ready.
|
|||||||
The peer-cli harness now exposes `cancel-download` so cancellation scenarios
|
The peer-cli harness now exposes `cancel-download` so cancellation scenarios
|
||||||
exercise the same runtime path as the GUI.
|
exercise the same runtime path as the GUI.
|
||||||
|
|
||||||
7. **Clean product semantics**
|
7. **Done — Clean product semantics**
|
||||||
|
|
||||||
Decide how the UI labels this state. It is installed but not downloaded, so
|
The UI now keeps streamed installs in the installed visual state while making
|
||||||
“Local only” is technically correct, but users may need a clear affordance
|
the sharing limitation explicit: cards show `Not shareable`, and the detail
|
||||||
like “Installed, not shareable”.
|
modal status shows `Installed, not shareable`. Downloaded-and-installed games
|
||||||
|
keep the normal `Installed` label.
|
||||||
|
|
||||||
My recommended next slice: make the provider abstraction final-ish, then
|
My recommended next slice: make the provider abstraction final-ish, then
|
||||||
implement a real one-pass provider. Everything else builds cleanly on that.
|
implement a real one-pass provider. Everything else builds cleanly on that.
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { Game } from '../lib/types';
|
import { Game } from '../lib/types';
|
||||||
import { deriveState } from '../lib/gameState';
|
import { deriveState, stateChipLabel } from '../lib/gameState';
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
|
||||||
installed: 'Installed',
|
|
||||||
local: 'Local',
|
|
||||||
downloading: 'Downloading',
|
|
||||||
busy: 'Working',
|
|
||||||
none: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game;
|
game: Game;
|
||||||
@@ -17,7 +9,7 @@ interface Props {
|
|||||||
|
|
||||||
export const StateChip = ({ game, showNone = false }: Props) => {
|
export const StateChip = ({ game, showNone = false }: Props) => {
|
||||||
const state = deriveState(game);
|
const state = deriveState(game);
|
||||||
const label = LABELS[state] ?? '';
|
const label = stateChipLabel(game);
|
||||||
if (!label && !showNone) return null;
|
if (!label && !showNone) return null;
|
||||||
return (
|
return (
|
||||||
<div className="state-chip" data-state={state}>
|
<div className="state-chip" data-state={state}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
|
|||||||
import { ActionButton } from '../ActionButton';
|
import { ActionButton } from '../ActionButton';
|
||||||
|
|
||||||
import { Game, InstallStatus } from '../../lib/types';
|
import { Game, InstallStatus } from '../../lib/types';
|
||||||
import { canStreamInstall, deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,16 +29,6 @@ const tagsFromGame = (game: Game): string[] => {
|
|||||||
return tags;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabelFor = (game: Game): string => {
|
|
||||||
switch (deriveState(game)) {
|
|
||||||
case 'installed': return 'Installed';
|
|
||||||
case 'local': return 'Downloaded';
|
|
||||||
case 'downloading': return 'Downloading';
|
|
||||||
case 'busy': return 'Working…';
|
|
||||||
case 'none': return 'Not downloaded';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GameDetailModal = ({
|
export const GameDetailModal = ({
|
||||||
game,
|
game,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
@@ -115,7 +105,7 @@ export const GameDetailModal = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="meta-cell">
|
<div className="meta-cell">
|
||||||
<div className="meta-label">Status</div>
|
<div className="meta-label">Status</div>
|
||||||
<div className="meta-value">{statusLabelFor(game)}</div>
|
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,35 @@ export const deriveState = (game: Game): DerivedState => {
|
|||||||
return 'none';
|
return 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isInstalledNotShareable = (game: Game): boolean =>
|
||||||
|
game.installed && !game.downloaded;
|
||||||
|
|
||||||
|
export const stateChipLabel = (game: Game): string => {
|
||||||
|
const state = deriveState(game);
|
||||||
|
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
|
||||||
|
switch (state) {
|
||||||
|
case 'installed': return 'Installed';
|
||||||
|
case 'local': return 'Local';
|
||||||
|
case 'downloading': return 'Downloading';
|
||||||
|
case 'busy': return 'Working';
|
||||||
|
case 'none': return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gameStatusLabel = (game: Game): string => {
|
||||||
|
const state = deriveState(game);
|
||||||
|
if (state === 'installed' && isInstalledNotShareable(game)) {
|
||||||
|
return 'Installed, not shareable';
|
||||||
|
}
|
||||||
|
switch (state) {
|
||||||
|
case 'installed': return 'Installed';
|
||||||
|
case 'local': return 'Downloaded';
|
||||||
|
case 'downloading': return 'Downloading';
|
||||||
|
case 'busy': return 'Working…';
|
||||||
|
case 'none': return 'Not downloaded';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const isUnavailable = (game: Game): boolean =>
|
export const isUnavailable = (game: Game): boolean =>
|
||||||
!game.installed
|
!game.installed
|
||||||
&& !game.downloaded
|
&& !game.downloaded
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
formatDownloadEta,
|
formatDownloadEta,
|
||||||
formatDownloadSpeed,
|
formatDownloadSpeed,
|
||||||
formatDownloadSpeedShort,
|
formatDownloadSpeedShort,
|
||||||
|
gameStatusLabel,
|
||||||
mergeGameUpdate,
|
mergeGameUpdate,
|
||||||
|
stateChipLabel,
|
||||||
} from '../src/lib/gameState.ts';
|
} from '../src/lib/gameState.ts';
|
||||||
import {
|
import {
|
||||||
ActiveOperationKind,
|
ActiveOperationKind,
|
||||||
@@ -243,3 +245,42 @@ Deno.test('stream install is available only for idle remote games', () => {
|
|||||||
'busy games should not expose streamed install',
|
'busy games should not expose streamed install',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('streamed local installs are labeled installed but not shareable', () => {
|
||||||
|
const streamed = game({
|
||||||
|
downloaded: false,
|
||||||
|
installed: true,
|
||||||
|
install_status: InstallStatus.Installed,
|
||||||
|
});
|
||||||
|
const downloadedInstall = game({
|
||||||
|
downloaded: true,
|
||||||
|
installed: true,
|
||||||
|
install_status: InstallStatus.Installed,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
deriveState(streamed),
|
||||||
|
'installed',
|
||||||
|
'streamed local installs should keep installed visual state',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
stateChipLabel(streamed),
|
||||||
|
'Not shareable',
|
||||||
|
'card chip should make the non-shareable state visible',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
gameStatusLabel(streamed),
|
||||||
|
'Installed, not shareable',
|
||||||
|
'detail status should spell out installed plus non-shareable',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
stateChipLabel(downloadedInstall),
|
||||||
|
'Installed',
|
||||||
|
'normal downloaded installs should keep the installed chip label',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
gameStatusLabel(downloadedInstall),
|
||||||
|
'Installed',
|
||||||
|
'normal downloaded installs should keep the installed detail label',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user