mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
test: Add tests for scrobble behavior when cleaning up players
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user