test: Add tests for scrobble behavior when cleaning up players

This commit is contained in:
FoxxMD
2025-03-13 11:06:26 -04:00
parent f11a2b5442
commit 38ea3115f6
5 changed files with 275 additions and 2 deletions
+7
View File
@@ -121,6 +121,7 @@
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.1.2",
"nodemon": "^3.0.3",
"ts-essentials": "^9.1.2",
@@ -7393,6 +7394,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/mockdate": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
"dev": true
},
"node_modules/mopidy": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/mopidy/-/mopidy-1.3.0.tgz",
+1
View File
@@ -153,6 +153,7 @@
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.1.2",
"nodemon": "^3.0.3",
"ts-essentials": "^9.1.2",
+10
View File
@@ -1,8 +1,18 @@
import { PlayObject } from "../../../core/Atomic.js";
import AbstractSource from "../../sources/AbstractSource.js";
import { MemoryPositionalSource } from "../../sources/MemoryPositionalSource.js";
import MemorySource from "../../sources/MemorySource.js";
export class TestSource extends AbstractSource {
handle(plays: PlayObject[]) {
this.scrobble(plays);
}
}
export class TestMemorySource extends MemoryPositionalSource {
}
export class TestMemoryPositionalSource extends MemoryPositionalSource {
}
+241 -2
View File
@@ -6,10 +6,15 @@ import { after, before, describe, it } from 'mocha';
import pEvent from "p-event";
import clone from 'clone';
import { PlayObject } from "../../../core/Atomic.js";
import { generatePlay } from "../utils/PlayTestUtils.js";
import { TestSource } from "./TestSource.js";
import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js";
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
import SpotifySource from "../../sources/SpotifySource.js";
import MockDate from 'mockdate';
import dayjs, { Dayjs } from "dayjs";
import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
import { SourceConfig } from "../../common/infrastructure/config/source/sources.js";
import MemorySource from "../../sources/MemorySource.js";
chai.use(asPromised);
@@ -20,6 +25,20 @@ const generateSource = () => {
}
let source: TestSource = generateSource();
const generateMemorySource = (config: SourceConfig = {}) => {
const s = new TestMemorySource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
s.buildTransformRules();
s.scheduler.stop();
return s;
}
const generateMemoryPositionalSource = (config: SourceConfig = {}) => {
const s = new TestMemoryPositionalSource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
s.buildTransformRules();
s.scheduler.stop();
return s;
}
describe('Sources use transform plays correctly', function () {
beforeEach(function() {
@@ -159,4 +178,224 @@ describe('Sources correctly parse incoming payloads', function () {
expect(identicalArtistsPlay.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']);
expect(identicalArtistsPlay.data.albumArtists).to.be.empty;
});
});
describe('Player Cleanup', function () {
this.afterEach(() => {
MockDate.reset();
});
const cleanedUpDuration = (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
const discoveredPlays = source.processRecentPlays([]);
// cleanup should discover stale play
expect(discoveredPlays.length).to.be.eq(1);
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
}
it('Discovers cleaned up Play with correct duration (Non Positional Source)', function () {
cleanedUpDuration(generateMemorySource);
});
it('Discovers cleaned up Play with correct duration (Positional Source)', function () {
cleanedUpDuration(generateMemoryPositionalSource);
});
const noScrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
timeSince += 2;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
const discoveredPlays = source.processRecentPlays([]);
// cleanup should discover stale play
expect(discoveredPlays.length).to.be.eq(1);
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
timeSince += 10;
position -= 9;
// simulate polling another 20 seconds with active source again
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
// new Play
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
// should not return play because it has only been played for ~20 seconds, less than 50% of duration
const plays = source.processRecentPlays([advancedState])
expect(plays.length).to.be.eq(0);
}
it('Does not discover same Play after becoming active again (Non Positional Source)', function () {
noScrobbleRediscoveryOnActive(generateMemorySource);
});
it('Does not discover same Play after becoming active again (Positional Source)', function () {
noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
});
const noScrobbleStale = (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
// if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
const discoveredPlays = source.processRecentPlays([]);
// cleanup should not discover stale play
expect(discoveredPlays.length).to.be.eq(0);
}
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', function () {
noScrobbleStale(generateMemorySource);
});
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', function () {
noScrobbleStale(generateMemoryPositionalSource);
});
const scrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
// if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
timeSince += 2;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
const discoveredPlays = source.processRecentPlays([]);
// cleanup should not discover stale play
expect(discoveredPlays.length).to.be.eq(0);
// so that loop starts 1 second after "paused" position
position -= 9;
// simulate ~50 seconds of listening (enough for scrobble)
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
for(let i = 0; i < 5; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
// new Play
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
// should return discovered play with ~90 seconds of duration
const plays = source.processRecentPlays([advancedState])
expect(plays.length).to.be.eq(1);
expect(plays[0].data.duration).to.be.closeTo(90, 2);
}
it('Does discover Play after becoming active again (Non Positional Source)', function () {
scrobbleRediscoveryOnActive(generateMemorySource);
});
it('Does discover Play after becoming active again (Positional Source)', function () {
scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
});
});
+16
View File
@@ -7,6 +7,7 @@ import timezone from "dayjs/plugin/timezone.js";
import utc from "dayjs/plugin/utc.js";
import { JsonPlayObject, ObjectPlayData, PlayMeta, PlayObject } from "../../../core/Atomic.js";
import { sortByNewestPlayDate } from "../../utils.js";
import { NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from '../../common/infrastructure/Atomic.js';
dayjs.extend(utc)
dayjs.extend(isBetween);
@@ -134,6 +135,21 @@ export const normalizePlays = (plays: PlayObject[],
return normalizedPlays;
}
export const generatePlayerStateData = (options: Omit<PlayerStateDataMaybePlay, 'platformId'> & {playData?: ObjectPlayData, playMeta?: PlayMeta, platformId?: PlayPlatformId} = {}): PlayerStateDataMaybePlay => {
let play: PlayObject = options.play ?? generatePlay(options.playData, options.playMeta);
if(options.position !== undefined) {
play.meta.trackProgressPosition = options.position;
}
return {
platformId: options.platformId ?? [NO_DEVICE, NO_USER],
sessionId: options.sessionId,
play,
status: options.status,
position: options.position,
timestamp: options.timestamp ?? dayjs()
}
}
export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject => {
return {
data: {