refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
This commit is contained in:
1 parent
c84f90e2cc
commit
e158558da3
56 files changed
+3337
-2196
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Общие правила клиентов
|
||||
|
||||
- Очередь/учёт событий: `src/player.ts`; волна: `src/wave.ts`; сессия: `src/auth.ts`.
|
||||
- Не импортируй web, React Native или системные media API. Платформы передают IO через узкие порты.
|
||||
- Поздние ответы старой wave-сессии не меняют новую; feedback после ошибки возвращается только в ту же сессию.
|
||||
- `pnpm --filter @pipu/core test`, `pnpm --filter @pipu/core typecheck`; после изменения экспортов проверь web и mobile.
|
||||
@@ -25,3 +25,5 @@ export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { trackShareUrl } from './share';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
|
||||
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WaveCoordinator, type WaveFeedback } from './wave';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('wave coordination', () => {
|
||||
it('preserves unsent feedback when changing context in the same session', async () => {
|
||||
const next = vi.fn().mockResolvedValue(1);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
wave.activate('a');
|
||||
wave.note('track', 'like');
|
||||
wave.changeContext('a');
|
||||
await wave.refill(0, true, () => {});
|
||||
expect(next).toHaveBeenCalledWith('a', [{ track_id: 'track', outcome: 'like' }]);
|
||||
});
|
||||
it('ignores late results and allows the new session to refill immediately', async () => {
|
||||
const old = deferred<number>();
|
||||
const next = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(2);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
const apply = vi.fn();
|
||||
wave.activate('old');
|
||||
const first = wave.refill(0, false, apply);
|
||||
wave.activate('new');
|
||||
await wave.refill(0, false, apply);
|
||||
old.resolve(1);
|
||||
await first;
|
||||
expect(apply.mock.calls.map(([value]) => value)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('retries feedback in order and respects threshold, refresh and single in-flight request', async () => {
|
||||
const request = deferred<number>();
|
||||
const next = vi.fn().mockReturnValueOnce(request.promise).mockResolvedValueOnce(2);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
const apply = vi.fn();
|
||||
wave.activate('a');
|
||||
wave.note('one', 'skip_early');
|
||||
await wave.refill(3, false, apply);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
const first = wave.refill(3, true, apply);
|
||||
wave.note('two', 'like');
|
||||
await wave.refill(0, false, apply);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
request.reject(new Error('offline'));
|
||||
await first;
|
||||
await wave.refill(2, false, apply);
|
||||
expect(next.mock.calls[1]).toEqual(['a', [
|
||||
{ track_id: 'one', outcome: 'skip_early' }, { track_id: 'two', outcome: 'like' },
|
||||
] satisfies WaveFeedback[]]);
|
||||
expect(apply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not restore old feedback or clear a newer in-flight request after a stale failure', async () => {
|
||||
const old = deferred<number>();
|
||||
const fresh = deferred<number>();
|
||||
const next = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
wave.activate('a');
|
||||
wave.note('old', 'dislike');
|
||||
const first = wave.refill(0, false, () => {});
|
||||
wave.activate('a'); // same session, new context
|
||||
wave.note('new', 'like');
|
||||
const second = wave.refill(0, false, () => {});
|
||||
old.reject(new Error('late failure'));
|
||||
await first;
|
||||
await wave.refill(0, false, () => {});
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(next.mock.calls[1]?.[1]).toEqual([{ track_id: 'new', outcome: 'like' }]);
|
||||
fresh.resolve(2);
|
||||
await second;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Policy shared by web and native players; queue ownership stays with the adapter. */
|
||||
export const EARLY_SKIP_MS = 30_000;
|
||||
export const WAVE_REFILL_AT = 2;
|
||||
export type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
activity: string | null;
|
||||
mood: string | null;
|
||||
fallback: boolean;
|
||||
}
|
||||
export interface WaveFeedback { track_id: string; outcome: WaveOutcome }
|
||||
export interface WaveGateway<T> {
|
||||
next(sessionId: string, feedback: WaveFeedback[]): Promise<T>;
|
||||
}
|
||||
|
||||
export class WaveCoordinator<T> {
|
||||
private sessionId: string | null = null;
|
||||
private generation = 0;
|
||||
private pending: WaveFeedback[] = [];
|
||||
private loading: number | null = null;
|
||||
|
||||
constructor(private readonly gateway: WaveGateway<T>) {}
|
||||
|
||||
get version() { return this.generation; }
|
||||
isCurrent(version: number) { return version === this.generation; }
|
||||
|
||||
/** Also invalidates in-flight responses when the context changes within a session. */
|
||||
activate(sessionId: string | null) {
|
||||
this.sessionId = sessionId;
|
||||
this.pending = [];
|
||||
this.loading = null;
|
||||
return ++this.generation;
|
||||
}
|
||||
|
||||
changeContext(sessionId: string) {
|
||||
const pending = this.sessionId === sessionId ? this.pending : [];
|
||||
const version = this.activate(sessionId);
|
||||
this.pending = pending;
|
||||
return version;
|
||||
}
|
||||
|
||||
note(trackId: string, outcome: WaveOutcome) {
|
||||
if (this.sessionId) this.pending.push({ track_id: trackId, outcome });
|
||||
}
|
||||
|
||||
async refill(remaining: number, refresh: boolean, apply: (result: T, current: () => boolean) => void | Promise<void>) {
|
||||
const id = this.sessionId;
|
||||
const version = this.generation;
|
||||
if (!id || this.loading === version || (!refresh && remaining > WAVE_REFILL_AT)) return;
|
||||
this.loading = version;
|
||||
const feedback = this.pending;
|
||||
this.pending = [];
|
||||
try {
|
||||
const result = await this.gateway.next(id, feedback);
|
||||
if (this.isCurrent(version)) await apply(result, () => this.isCurrent(version));
|
||||
} catch {
|
||||
if (this.isCurrent(version)) this.pending = [...feedback, ...this.pending];
|
||||
} finally {
|
||||
if (this.loading === version) this.loading = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user