madden-fantasy-extractor 0.2.0
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/README.md +255 -0
- package/package.json +25 -0
- package/src/cli.js +128 -0
- package/src/extract/clock.js +75 -0
- package/src/extract/cumulative-stats.js +35 -0
- package/src/extract/depth-chart.js +56 -0
- package/src/extract/games.js +78 -0
- package/src/extract/payload.js +125 -0
- package/src/extract/player-game-stats.js +86 -0
- package/src/extract/player-week-status.js +189 -0
- package/src/extract/players.js +74 -0
- package/src/extract/team-game-stats.js +153 -0
- package/src/extract/teams.js +41 -0
- package/src/extract/transactions.js +105 -0
- package/src/index.js +4 -0
- package/src/lib/franchise-table.js +88 -0
- package/src/lib/identity.js +58 -0
- package/src/lib/incremental.js +90 -0
- package/src/lib/signing.js +48 -0
- package/src/lib/stat-categories.js +68 -0
- package/src/lib/state.js +30 -0
- package/test/helpers/fake-franchise.js +36 -0
- package/test/identity.test.js +61 -0
- package/test/incremental.test.js +147 -0
- package/test/player-game-stats.test.js +85 -0
- package/test/player-week-status.test.js +173 -0
- package/test/state.test.js +54 -0
- package/test/team-game-stats.test.js +119 -0
- package/test/transactions.test.js +118 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { selectGamesToCapture, selectReadyGames, selectPendingGames, unionCapturedKeys, seasonCaptureKey } from '../src/lib/incremental.js';
|
|
4
|
+
import { gameKey } from '../src/extract/games.js';
|
|
5
|
+
|
|
6
|
+
function game(seasonWeekType, seasonWeek, home, away, gameStatus = 'HomeWon') {
|
|
7
|
+
return { seasonWeekType, seasonWeek, homeExternalTeamId: home, awayExternalTeamId: away, gameStatus };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test('selectGamesToCapture: fullMode returns every game unfiltered', () => {
|
|
11
|
+
const games = [
|
|
12
|
+
game('RegularSeason', 0, 10, 20, 'HomeWon'),
|
|
13
|
+
game('RegularSeason', 1, 10, 30, 'Unplayed'),
|
|
14
|
+
];
|
|
15
|
+
const result = selectGamesToCapture(games, { fullMode: true, seasonIndex: 0, alreadyCapturedKeys: new Set() });
|
|
16
|
+
assert.deepEqual(result, games);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('selectGamesToCapture: normal mode excludes undecided games', () => {
|
|
20
|
+
const games = [
|
|
21
|
+
game('RegularSeason', 0, 10, 20, 'HomeWon'),
|
|
22
|
+
game('RegularSeason', 1, 10, 30, 'Unplayed'),
|
|
23
|
+
game('RegularSeason', 2, 10, 40, 'Unscheduled'),
|
|
24
|
+
];
|
|
25
|
+
const result = selectGamesToCapture(games, { fullMode: false, seasonIndex: 0, alreadyCapturedKeys: new Set() });
|
|
26
|
+
assert.equal(result.length, 1);
|
|
27
|
+
assert.equal(result[0].seasonWeek, 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('selectGamesToCapture: normal mode excludes games already in the watermark', () => {
|
|
31
|
+
const g1 = game('RegularSeason', 0, 10, 20, 'HomeWon');
|
|
32
|
+
const g2 = game('RegularSeason', 1, 10, 30, 'AwayWon');
|
|
33
|
+
const alreadyCapturedKeys = new Set([seasonCaptureKey(0, g1)]);
|
|
34
|
+
|
|
35
|
+
const result = selectGamesToCapture([g1, g2], { fullMode: false, seasonIndex: 0, alreadyCapturedKeys });
|
|
36
|
+
assert.equal(result.length, 1);
|
|
37
|
+
assert.equal(result[0].seasonWeek, 1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('selectGamesToCapture: a Tie counts as decided', () => {
|
|
41
|
+
const g = game('RegularSeason', 0, 10, 20, 'Tie');
|
|
42
|
+
const result = selectGamesToCapture([g], { fullMode: false, seasonIndex: 0, alreadyCapturedKeys: new Set() });
|
|
43
|
+
assert.equal(result.length, 1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('unionCapturedKeys: adds new keys without dropping existing ones', () => {
|
|
47
|
+
const g1 = game('RegularSeason', 0, 10, 20);
|
|
48
|
+
const g2 = game('RegularSeason', 1, 10, 30);
|
|
49
|
+
const existing = new Set([seasonCaptureKey(0, g1)]);
|
|
50
|
+
|
|
51
|
+
const next = unionCapturedKeys(existing, [g2], 0);
|
|
52
|
+
assert.equal(next.size, 2);
|
|
53
|
+
assert.ok(next.has(seasonCaptureKey(0, g1)));
|
|
54
|
+
assert.ok(next.has(seasonCaptureKey(0, g2)));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('unionCapturedKeys: does not mutate the input set', () => {
|
|
58
|
+
const g1 = game('RegularSeason', 0, 10, 20);
|
|
59
|
+
const existing = new Set();
|
|
60
|
+
unionCapturedKeys(existing, [g1], 0);
|
|
61
|
+
assert.equal(existing.size, 0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('seasonCaptureKey: disambiguates an identical matchup/week across two seasons', () => {
|
|
65
|
+
// The payload's own gameKey is season-agnostic by design (games.js) — sufficient
|
|
66
|
+
// within one extraction run, but two different seasons CAN report the same
|
|
67
|
+
// week-type/index/home/away combination. seasonCaptureKey must not collide here,
|
|
68
|
+
// or a new season's opening week would be silently treated as "already captured"
|
|
69
|
+
// from a stale watermark left over from the prior season.
|
|
70
|
+
const g = game('RegularSeason', 0, 10, 20);
|
|
71
|
+
const season0Key = seasonCaptureKey(0, g);
|
|
72
|
+
const season1Key = seasonCaptureKey(1, g);
|
|
73
|
+
assert.notEqual(season0Key, season1Key);
|
|
74
|
+
|
|
75
|
+
// and confirm the underlying gameKey really is identical across both calls, i.e.
|
|
76
|
+
// the disambiguation is coming from the season prefix, not from gameKey itself
|
|
77
|
+
assert.equal(gameKey(g), gameKey(g));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a freshly-decided game that produced no player stats yet is retried next run', () => {
|
|
81
|
+
// Simulates the week-lag scenario: a game is decided (selected this run) but
|
|
82
|
+
// produced zero player_game_stat rows (caller would compute readyGames as empty
|
|
83
|
+
// for it), so it must NOT be added to the watermark, and must be selected again
|
|
84
|
+
// on the next run.
|
|
85
|
+
const g = game('RegularSeason', 5, 10, 20, 'HomeWon');
|
|
86
|
+
const alreadyCapturedKeys = new Set(); // nothing marked captured — simulates "not ready" outcome
|
|
87
|
+
|
|
88
|
+
const selected = selectGamesToCapture([g], { fullMode: false, seasonIndex: 0, alreadyCapturedKeys });
|
|
89
|
+
assert.equal(selected.length, 1, 'game is selected again since it was never added to the watermark');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('selectReadyGames: a RegularSeason game is only ready once it produced a player_game_stat row', () => {
|
|
93
|
+
const decidedNoStats = game('RegularSeason', 5, 10, 20, 'HomeWon');
|
|
94
|
+
const decidedWithStats = game('RegularSeason', 4, 10, 20, 'HomeWon');
|
|
95
|
+
const playerGameStats = [{ gameKey: gameKey(decidedWithStats), statCategoryKey: 'pass_yards', value: 200 }];
|
|
96
|
+
|
|
97
|
+
const ready = selectReadyGames([decidedNoStats, decidedWithStats], playerGameStats);
|
|
98
|
+
assert.equal(ready.length, 1);
|
|
99
|
+
assert.equal(ready[0].seasonWeek, 4);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('selectReadyGames: PreSeason games are ready as soon as decided, with zero player stats', () => {
|
|
103
|
+
// The carve-out: confirmed against a real save that PreSeason games NEVER produce
|
|
104
|
+
// player_game_stat rows in this data model, so gating on stat presence would loop
|
|
105
|
+
// them forever. Decided is both correct and the only signal available here.
|
|
106
|
+
const decidedPreseason = game('PreSeason', 0, 10, 20, 'HomeWon');
|
|
107
|
+
const undecidedPreseason = game('PreSeason', 1, 10, 30, 'Unplayed');
|
|
108
|
+
|
|
109
|
+
const ready = selectReadyGames([decidedPreseason, undecidedPreseason], []);
|
|
110
|
+
assert.equal(ready.length, 1);
|
|
111
|
+
assert.equal(ready[0].seasonWeek, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('selectReadyGames: a game producing stats does not make an unrelated game ready', () => {
|
|
115
|
+
const g1 = game('RegularSeason', 0, 10, 20, 'HomeWon');
|
|
116
|
+
const g2 = game('RegularSeason', 1, 10, 30, 'HomeWon');
|
|
117
|
+
const playerGameStats = [{ gameKey: gameKey(g1), statCategoryKey: 'pass_yards', value: 200 }];
|
|
118
|
+
|
|
119
|
+
const ready = selectReadyGames([g1, g2], playerGameStats);
|
|
120
|
+
assert.deepEqual(ready.map((g) => g.seasonWeek), [0]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('selectPendingGames: the exact complement of selectReadyGames', () => {
|
|
124
|
+
const ready1 = game('RegularSeason', 0, 10, 20, 'HomeWon');
|
|
125
|
+
const stillWaiting = game('RegularSeason', 1, 10, 30, 'HomeWon');
|
|
126
|
+
const playerGameStats = [{ gameKey: gameKey(ready1), statCategoryKey: 'pass_yards', value: 200 }];
|
|
127
|
+
|
|
128
|
+
const pending = selectPendingGames([ready1, stillWaiting], playerGameStats);
|
|
129
|
+
assert.equal(pending.length, 1);
|
|
130
|
+
assert.equal(pending[0].seasonWeek, 1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('selectPendingGames: empty when every included game is ready', () => {
|
|
134
|
+
const g1 = game('RegularSeason', 0, 10, 20, 'HomeWon');
|
|
135
|
+
const g2 = game('PreSeason', 0, 10, 20, 'HomeWon'); // ready via the PreSeason carve-out
|
|
136
|
+
const playerGameStats = [{ gameKey: gameKey(g1), statCategoryKey: 'pass_yards', value: 200 }];
|
|
137
|
+
|
|
138
|
+
const pending = selectPendingGames([g1, g2], playerGameStats);
|
|
139
|
+
assert.deepEqual(pending, []);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('selectPendingGames: every game pending when none produced stats yet', () => {
|
|
143
|
+
const g1 = game('RegularSeason', 0, 10, 20, 'HomeWon');
|
|
144
|
+
const g2 = game('RegularSeason', 1, 10, 30, 'AwayWon');
|
|
145
|
+
const pending = selectPendingGames([g1, g2], []);
|
|
146
|
+
assert.equal(pending.length, 2);
|
|
147
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractPlayerGameStats } from '../src/extract/player-game-stats.js';
|
|
4
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
5
|
+
import { gameKey } from '../src/extract/games.js';
|
|
6
|
+
|
|
7
|
+
test('zero and missing stat values are skipped; nonzero values are kept', async () => {
|
|
8
|
+
const teamTable = fakeTable('Team', [fakeRecord({ TeamIndex: 0 }), fakeRecord({ TeamIndex: 1 })]);
|
|
9
|
+
|
|
10
|
+
const seasonGame = fakeRecord(
|
|
11
|
+
{ SeasonWeekType: 'RegularSeason', SeasonWeek: 0 },
|
|
12
|
+
{
|
|
13
|
+
HomeTeam: { tableId: teamTable._id, rowNumber: 0 },
|
|
14
|
+
AwayTeam: { tableId: teamTable._id, rowNumber: 1 },
|
|
15
|
+
},
|
|
16
|
+
);
|
|
17
|
+
const seasonGameTable = fakeTable('SeasonGame', [seasonGame]);
|
|
18
|
+
|
|
19
|
+
// PASSYARDS/PASSTDS nonzero, RECEIVEYARDS explicitly 0, RUSHTDS missing entirely —
|
|
20
|
+
// all three should behave identically (skipped) except the two nonzero fields.
|
|
21
|
+
const statRow = fakeRecord(
|
|
22
|
+
{ PASSYARDS: 260, RECEIVEYARDS: 0, RUSHTDS: undefined, PASSTDS: 1 },
|
|
23
|
+
{ SeasonGame: { tableId: seasonGameTable._id, rowNumber: 0 } },
|
|
24
|
+
);
|
|
25
|
+
const statTable = fakeTable('GameOffensiveStats', [statRow]);
|
|
26
|
+
|
|
27
|
+
const wrapper = fakeRecord({}, { GameStats0: { tableId: statTable._id, rowNumber: 0 } });
|
|
28
|
+
const wrapperTable = fakeTable('GameStats[]', [wrapper]);
|
|
29
|
+
|
|
30
|
+
const playerRecord = fakeRecord(
|
|
31
|
+
{ PresentationId: 999 },
|
|
32
|
+
{ GameStats: { tableId: wrapperTable._id, rowNumber: 0 } },
|
|
33
|
+
);
|
|
34
|
+
const player = { externalPlayerId: 999, _record: playerRecord };
|
|
35
|
+
|
|
36
|
+
const franchise = fakeFranchise([teamTable, seasonGameTable, statTable, wrapperTable]);
|
|
37
|
+
const indexTeamsByTeamIndex = new Map([
|
|
38
|
+
[0, { externalTeamId: 10, teamIndex: 0 }],
|
|
39
|
+
[1, { externalTeamId: 20, teamIndex: 1 }],
|
|
40
|
+
]);
|
|
41
|
+
const key = gameKey({ seasonWeekType: 'RegularSeason', seasonWeek: 0, homeExternalTeamId: 10, awayExternalTeamId: 20 });
|
|
42
|
+
const gamesByKey = new Map([[key, {}]]);
|
|
43
|
+
|
|
44
|
+
const rows = await extractPlayerGameStats(franchise, { players: [player], gamesByKey, indexTeamsByTeamIndex });
|
|
45
|
+
|
|
46
|
+
const byCategory = new Map(rows.map((r) => [r.statCategoryKey, r.value]));
|
|
47
|
+
assert.equal(rows.length, 2, 'only the two nonzero categories should be emitted');
|
|
48
|
+
assert.equal(byCategory.get('pass_yards'), 260);
|
|
49
|
+
assert.equal(byCategory.get('pass_tds'), 1);
|
|
50
|
+
assert.equal(byCategory.has('rec_yards'), false, 'explicit zero value must be skipped');
|
|
51
|
+
assert.equal(byCategory.has('rush_tds'), false, 'missing/undefined value must be skipped');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a player with every category zero produces no rows for that game', async () => {
|
|
55
|
+
const teamTable = fakeTable('Team', [fakeRecord({ TeamIndex: 0 }), fakeRecord({ TeamIndex: 1 })]);
|
|
56
|
+
const seasonGame = fakeRecord(
|
|
57
|
+
{ SeasonWeekType: 'RegularSeason', SeasonWeek: 0 },
|
|
58
|
+
{
|
|
59
|
+
HomeTeam: { tableId: teamTable._id, rowNumber: 0 },
|
|
60
|
+
AwayTeam: { tableId: teamTable._id, rowNumber: 1 },
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
const seasonGameTable = fakeTable('SeasonGame', [seasonGame]);
|
|
64
|
+
|
|
65
|
+
const statRow = fakeRecord(
|
|
66
|
+
{ PASSYARDS: 0, PASSTDS: 0, PASSATTEMPTS: 0 },
|
|
67
|
+
{ SeasonGame: { tableId: seasonGameTable._id, rowNumber: 0 } },
|
|
68
|
+
);
|
|
69
|
+
const statTable = fakeTable('GameOffensiveStats', [statRow]);
|
|
70
|
+
const wrapper = fakeRecord({}, { GameStats0: { tableId: statTable._id, rowNumber: 0 } });
|
|
71
|
+
const wrapperTable = fakeTable('GameStats[]', [wrapper]);
|
|
72
|
+
const playerRecord = fakeRecord({ PresentationId: 1 }, { GameStats: { tableId: wrapperTable._id, rowNumber: 0 } });
|
|
73
|
+
const player = { externalPlayerId: 1, _record: playerRecord };
|
|
74
|
+
|
|
75
|
+
const franchise = fakeFranchise([teamTable, seasonGameTable, statTable, wrapperTable]);
|
|
76
|
+
const indexTeamsByTeamIndex = new Map([
|
|
77
|
+
[0, { externalTeamId: 10, teamIndex: 0 }],
|
|
78
|
+
[1, { externalTeamId: 20, teamIndex: 1 }],
|
|
79
|
+
]);
|
|
80
|
+
const key = gameKey({ seasonWeekType: 'RegularSeason', seasonWeek: 0, homeExternalTeamId: 10, awayExternalTeamId: 20 });
|
|
81
|
+
const gamesByKey = new Map([[key, {}]]);
|
|
82
|
+
|
|
83
|
+
const rows = await extractPlayerGameStats(franchise, { players: [player], gamesByKey, indexTeamsByTeamIndex });
|
|
84
|
+
assert.deepEqual(rows, []);
|
|
85
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractPlayerWeekStatus } from '../src/extract/player-week-status.js';
|
|
4
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
5
|
+
|
|
6
|
+
function game(seasonWeekType, seasonWeek, homeExternalTeamId, awayExternalTeamId) {
|
|
7
|
+
return { seasonWeekType, seasonWeek, homeExternalTeamId, awayExternalTeamId };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function fakePlayer({ externalPlayerId, teamIndex, contractStatus = 'Signed', injuryStatus = 'Uninjured', gameStatsWrapper = null }) {
|
|
11
|
+
const refs = {};
|
|
12
|
+
if (gameStatsWrapper) refs.GameStats = { tableId: gameStatsWrapper._id, rowNumber: 0 };
|
|
13
|
+
const record = fakeRecord(
|
|
14
|
+
{
|
|
15
|
+
PresentationId: externalPlayerId,
|
|
16
|
+
ContractStatus: contractStatus,
|
|
17
|
+
InjuryStatus: injuryStatus,
|
|
18
|
+
InjuryType: null,
|
|
19
|
+
InjurySeverity: null,
|
|
20
|
+
MinInjuryDuration: 0,
|
|
21
|
+
MaxInjuryDuration: 0,
|
|
22
|
+
IsInjuredReserve: false,
|
|
23
|
+
CurrentYearSeasonEndingInjuryWeek: 30,
|
|
24
|
+
},
|
|
25
|
+
refs,
|
|
26
|
+
);
|
|
27
|
+
return { externalPlayerId, teamIndex, _record: record };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Builds a GameStats[] wrapper table pointing at one stat row, which itself points
|
|
31
|
+
// at a SeasonGame row for the given week — the chain hasStatRowForWeek walks.
|
|
32
|
+
function fakeGameStatsChain(weekType, weekIndex) {
|
|
33
|
+
const seasonGame = fakeRecord({ SeasonWeekType: weekType, SeasonWeek: weekIndex });
|
|
34
|
+
const seasonGameTable = fakeTable('SeasonGame', [seasonGame]);
|
|
35
|
+
const statRow = fakeRecord({}, { SeasonGame: { tableId: seasonGameTable._id, rowNumber: 0 } });
|
|
36
|
+
const statTable = fakeTable('GameOffensiveStats', [statRow]);
|
|
37
|
+
const wrapper = fakeRecord({}, { GameStats0: { tableId: statTable._id, rowNumber: 0 } });
|
|
38
|
+
const wrapperTable = fakeTable('GameStats[]', [wrapper]);
|
|
39
|
+
return { wrapperTable, extraTables: [seasonGameTable, statTable] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function byWeekIndex(rows, weekIndex) {
|
|
43
|
+
return rows.filter((r) => r.weekIndex === weekIndex);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('emits both a predictive (current week) and retrospective (completed week) row', async () => {
|
|
47
|
+
const { wrapperTable, extraTables } = fakeGameStatsChain('RegularSeason', 12);
|
|
48
|
+
const player = fakePlayer({ externalPlayerId: 1, teamIndex: 0, gameStatsWrapper: wrapperTable });
|
|
49
|
+
|
|
50
|
+
const games = [game('RegularSeason', 12, 10, 20), game('RegularSeason', 13, 10, 30)];
|
|
51
|
+
const franchise = fakeFranchise([wrapperTable, ...extraTables]);
|
|
52
|
+
const clock = { stage: 'NFLSeason', weekType: 'RegularSeason', weekIndex: 13 };
|
|
53
|
+
const indexTeamsByTeamIndex = new Map([[0, { externalTeamId: 10, teamIndex: 0 }]]);
|
|
54
|
+
|
|
55
|
+
const rows = await extractPlayerWeekStatus(franchise, { players: [player], games, clock, indexTeamsByTeamIndex });
|
|
56
|
+
assert.equal(rows.length, 2);
|
|
57
|
+
|
|
58
|
+
const retrospective = byWeekIndex(rows, 12)[0];
|
|
59
|
+
assert.equal(retrospective.status, 'active'); // confirmed played, via the GameStats chain
|
|
60
|
+
|
|
61
|
+
const predictive = byWeekIndex(rows, 13)[0];
|
|
62
|
+
assert.equal(predictive.status, 'active'); // healthy + rostered + team plays week 13 -> predicted active
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('predictive row is available at weekIndex 0 (no prior-week dependency), unlike retrospective', async () => {
|
|
66
|
+
const franchise = fakeFranchise([]);
|
|
67
|
+
const clock = { stage: 'NFLSeason', weekType: 'RegularSeason', weekIndex: 0 };
|
|
68
|
+
const rows = await extractPlayerWeekStatus(franchise, {
|
|
69
|
+
players: [fakePlayer({ externalPlayerId: 1, teamIndex: 0 })],
|
|
70
|
+
games: [game('PreSeason', 2, 10, 20), game('RegularSeason', 0, 10, 20)],
|
|
71
|
+
clock,
|
|
72
|
+
indexTeamsByTeamIndex: new Map([[0, { externalTeamId: 10, teamIndex: 0 }]]),
|
|
73
|
+
});
|
|
74
|
+
// Only the predictive row — no completed week exists yet at weekIndex 0, and the
|
|
75
|
+
// PreSeason->RegularSeason boundary is confirmed-ambiguous (see module header).
|
|
76
|
+
assert.equal(rows.length, 1);
|
|
77
|
+
assert.equal(rows[0].weekType, 'RegularSeason');
|
|
78
|
+
assert.equal(rows[0].weekIndex, 0);
|
|
79
|
+
assert.equal(rows[0].status, 'active');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('predictive row never reports inactive — healthy+rostered+scheduled always reads active', async () => {
|
|
83
|
+
// No GameStats chain at all for this player (as if none has ever been resolved) —
|
|
84
|
+
// under retrospective logic this would be 'inactive', but predictive mode can't
|
|
85
|
+
// know that ahead of kickoff and must not claim it.
|
|
86
|
+
const franchise = fakeFranchise([]);
|
|
87
|
+
const games = [game('RegularSeason', 13, 10, 20)];
|
|
88
|
+
const clock = { stage: 'NFLSeason', weekType: 'RegularSeason', weekIndex: 13 };
|
|
89
|
+
const indexTeamsByTeamIndex = new Map([[0, { externalTeamId: 10, teamIndex: 0 }]]);
|
|
90
|
+
const player = fakePlayer({ externalPlayerId: 1, teamIndex: 0 });
|
|
91
|
+
|
|
92
|
+
const rows = await extractPlayerWeekStatus(franchise, { players: [player], games, clock, indexTeamsByTeamIndex });
|
|
93
|
+
const predictive = byWeekIndex(rows, 13)[0];
|
|
94
|
+
assert.equal(predictive.status, 'active');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('crosses the RegularSeason -> WildcardPlayoff boundary correctly for the retrospective row', async () => {
|
|
98
|
+
// clock says WildcardPlayoff week 18; the completed week is RegularSeason 17, NOT
|
|
99
|
+
// "WildcardPlayoff 17" (which doesn't exist) — this is the bug that was found and
|
|
100
|
+
// fixed against real save data.
|
|
101
|
+
const games = [
|
|
102
|
+
game('RegularSeason', 17, 10, 20),
|
|
103
|
+
game('WildcardPlayoff', 18, 10, 40),
|
|
104
|
+
];
|
|
105
|
+
const player = fakePlayer({ externalPlayerId: 1, teamIndex: 0 }); // no stat chain -> inactive, that's fine here
|
|
106
|
+
const franchise = fakeFranchise([]);
|
|
107
|
+
const clock = { stage: 'NFLSeason', weekType: 'WildcardPlayoff', weekIndex: 18 };
|
|
108
|
+
const indexTeamsByTeamIndex = new Map([[0, { externalTeamId: 10, teamIndex: 0 }]]);
|
|
109
|
+
|
|
110
|
+
const rows = await extractPlayerWeekStatus(franchise, { players: [player], games, clock, indexTeamsByTeamIndex });
|
|
111
|
+
const retrospective = byWeekIndex(rows, 17)[0];
|
|
112
|
+
assert.equal(retrospective.weekType, 'RegularSeason');
|
|
113
|
+
|
|
114
|
+
const predictive = byWeekIndex(rows, 18)[0];
|
|
115
|
+
assert.equal(predictive.weekType, 'WildcardPlayoff');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('returns empty during the offseason', async () => {
|
|
119
|
+
const franchise = fakeFranchise([]);
|
|
120
|
+
const clock = { stage: 'OffSeason', weekType: 'OffSeason', weekIndex: 4 };
|
|
121
|
+
const rows = await extractPlayerWeekStatus(franchise, {
|
|
122
|
+
players: [fakePlayer({ externalPlayerId: 1, teamIndex: 0 })],
|
|
123
|
+
games: [],
|
|
124
|
+
clock,
|
|
125
|
+
indexTeamsByTeamIndex: new Map(),
|
|
126
|
+
});
|
|
127
|
+
assert.deepEqual(rows, []);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('classifies bye, injured, and free_agent correctly in both predictive and retrospective rows', async () => {
|
|
131
|
+
const games = [game('RegularSeason', 4, 10, 20)]; // only teams 10,20 play week 4; team 30 is on bye
|
|
132
|
+
const franchise = fakeFranchise([]);
|
|
133
|
+
const clock = { stage: 'NFLSeason', weekType: 'RegularSeason', weekIndex: 5 };
|
|
134
|
+
const indexTeamsByTeamIndex = new Map([
|
|
135
|
+
[0, { externalTeamId: 10, teamIndex: 0 }],
|
|
136
|
+
[2, { externalTeamId: 30, teamIndex: 2 }],
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const players = [
|
|
140
|
+
fakePlayer({ externalPlayerId: 1, teamIndex: 2 }), // bye (team 30 has no game week 4)
|
|
141
|
+
fakePlayer({ externalPlayerId: 2, teamIndex: 0, injuryStatus: 'Injured' }), // injured takes priority
|
|
142
|
+
fakePlayer({ externalPlayerId: 3, teamIndex: 0, contractStatus: 'FreeAgent' }), // free_agent takes priority
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const rows = await extractPlayerWeekStatus(franchise, { players, games, clock, indexTeamsByTeamIndex });
|
|
146
|
+
const retrospective = byWeekIndex(rows, 4);
|
|
147
|
+
const byId = new Map(retrospective.map((r) => [r.externalPlayerId, r.status]));
|
|
148
|
+
assert.equal(byId.get(1), 'bye');
|
|
149
|
+
assert.equal(byId.get(2), 'injured');
|
|
150
|
+
assert.equal(byId.get(3), 'free_agent');
|
|
151
|
+
|
|
152
|
+
// injured/bye/free_agent are live fields — no reason for the predictive row (week 5,
|
|
153
|
+
// still bye/injured/FA in this test's fixed player state) to disagree with retrospective.
|
|
154
|
+
const predictive = byWeekIndex(rows, 5);
|
|
155
|
+
const predictiveById = new Map(predictive.map((r) => [r.externalPlayerId, r.status]));
|
|
156
|
+
assert.equal(predictiveById.get(1), 'bye');
|
|
157
|
+
assert.equal(predictiveById.get(2), 'injured');
|
|
158
|
+
assert.equal(predictiveById.get(3), 'free_agent');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a newly-injured player is reflected immediately in the predictive row, no lag', async () => {
|
|
162
|
+
// Mirrors the real-save finding: injury status is a live field, unlike GameStats.
|
|
163
|
+
// A player injured in the game that JUST decided this week shows up right away.
|
|
164
|
+
const games = [game('RegularSeason', 13, 10, 20)];
|
|
165
|
+
const franchise = fakeFranchise([]);
|
|
166
|
+
const clock = { stage: 'NFLSeason', weekType: 'RegularSeason', weekIndex: 13 };
|
|
167
|
+
const indexTeamsByTeamIndex = new Map([[0, { externalTeamId: 10, teamIndex: 0 }]]);
|
|
168
|
+
const player = fakePlayer({ externalPlayerId: 1, teamIndex: 0, injuryStatus: 'Injured' });
|
|
169
|
+
|
|
170
|
+
const rows = await extractPlayerWeekStatus(franchise, { players: [player], games, clock, indexTeamsByTeamIndex });
|
|
171
|
+
const predictive = byWeekIndex(rows, 13)[0];
|
|
172
|
+
assert.equal(predictive.status, 'injured');
|
|
173
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { loadState, saveState } from '../src/lib/state.js';
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
function withTempDir(fn) {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), 'extractor-state-test-'));
|
|
10
|
+
try {
|
|
11
|
+
return fn(dir);
|
|
12
|
+
} finally {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('loadState returns null when no file exists', () => {
|
|
18
|
+
withTempDir((dir) => {
|
|
19
|
+
const state = loadState(join(dir, 'nope.json'));
|
|
20
|
+
assert.equal(state, null);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('saveState then loadState round-trips the captured key set', () => {
|
|
25
|
+
withTempDir((dir) => {
|
|
26
|
+
const statePath = join(dir, 'state.json');
|
|
27
|
+
const keys = new Set(['0:RegularSeason:0:3:25', '0:RegularSeason:1:3:19']);
|
|
28
|
+
|
|
29
|
+
saveState(statePath, { capturedGameKeys: keys, toolVersion: '0.1.0', savePath: '/some/save' });
|
|
30
|
+
const loaded = loadState(statePath);
|
|
31
|
+
|
|
32
|
+
assert.deepEqual([...loaded.capturedGameKeys].sort(), [...keys].sort());
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('saveState creates parent directories that do not exist yet', () => {
|
|
37
|
+
withTempDir((dir) => {
|
|
38
|
+
const statePath = join(dir, 'nested', 'deeper', 'state.json');
|
|
39
|
+
saveState(statePath, { capturedGameKeys: new Set(['a']), toolVersion: '0.1.0', savePath: 'x' });
|
|
40
|
+
const loaded = loadState(statePath);
|
|
41
|
+
assert.ok(loaded.capturedGameKeys.has('a'));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('saveState overwrites a previous state file (not append)', () => {
|
|
46
|
+
withTempDir((dir) => {
|
|
47
|
+
const statePath = join(dir, 'state.json');
|
|
48
|
+
saveState(statePath, { capturedGameKeys: new Set(['a']), toolVersion: '0.1.0', savePath: 'x' });
|
|
49
|
+
saveState(statePath, { capturedGameKeys: new Set(['b']), toolVersion: '0.1.0', savePath: 'x' });
|
|
50
|
+
|
|
51
|
+
const loaded = loadState(statePath);
|
|
52
|
+
assert.deepEqual([...loaded.capturedGameKeys], ['b']);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractTeamGameStats } from '../src/extract/team-game-stats.js';
|
|
4
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
5
|
+
|
|
6
|
+
function buildGame({ homeScore, awayScore, homeStats, awayStats }) {
|
|
7
|
+
const teamTable = fakeTable('Team', [fakeRecord({ TeamIndex: 0 }), fakeRecord({ TeamIndex: 1 })]);
|
|
8
|
+
const statCacheTable = fakeTable('TeamStats', [fakeRecord(homeStats), fakeRecord(awayStats)]);
|
|
9
|
+
|
|
10
|
+
const seasonGameRecord = fakeRecord(
|
|
11
|
+
{},
|
|
12
|
+
{
|
|
13
|
+
HomeTeam: { tableId: teamTable._id, rowNumber: 0 },
|
|
14
|
+
AwayTeam: { tableId: teamTable._id, rowNumber: 1 },
|
|
15
|
+
HomeTeamStatCache: { tableId: statCacheTable._id, rowNumber: 0 },
|
|
16
|
+
AwayTeamStatCache: { tableId: statCacheTable._id, rowNumber: 1 },
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const franchise = fakeFranchise([teamTable, statCacheTable]);
|
|
21
|
+
const indexTeamsByTeamIndex = new Map([
|
|
22
|
+
[0, { externalTeamId: 10, teamIndex: 0 }],
|
|
23
|
+
[1, { externalTeamId: 20, teamIndex: 1 }],
|
|
24
|
+
]);
|
|
25
|
+
const game = {
|
|
26
|
+
seasonWeekType: 'RegularSeason',
|
|
27
|
+
seasonWeek: 0,
|
|
28
|
+
homeExternalTeamId: 10,
|
|
29
|
+
awayExternalTeamId: 20,
|
|
30
|
+
homeScore,
|
|
31
|
+
awayScore,
|
|
32
|
+
_record: seasonGameRecord,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return { franchise, indexTeamsByTeamIndex, game };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('a shutout (0 points/0 yards allowed) still emits dst_points_allowed and dst_yards_allowed', async () => {
|
|
39
|
+
// Home team shuts the away team out: home allowed 0 points, 0 yards, and also
|
|
40
|
+
// recorded 0 sacks/takeaways this game (a plausible shutout-by-turnovers-and-D line).
|
|
41
|
+
const { franchise, indexTeamsByTeamIndex, game } = buildGame({
|
|
42
|
+
homeScore: 24,
|
|
43
|
+
awayScore: 0,
|
|
44
|
+
homeStats: { SACKS: 0, TAKEAWAYS: 0, TWOPOINTCONVMADE: 0, DEFPASSYARDS: 0, DEFRUSHYARDS: 0 },
|
|
45
|
+
awayStats: { SACKS: 1, TAKEAWAYS: 1, TWOPOINTCONVMADE: 0, DEFPASSYARDS: 150, DEFRUSHYARDS: 50 },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const rows = await extractTeamGameStats(franchise, {
|
|
49
|
+
games: [game],
|
|
50
|
+
playerGameStatsByGameAndTeam: new Map(),
|
|
51
|
+
indexTeamsByTeamIndex,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const homeRows = new Map(rows.filter((r) => r.externalTeamId === 10).map((r) => [r.statCategoryKey, r.value]));
|
|
55
|
+
|
|
56
|
+
// Linear categories at 0: skipped (scoring-identical to a missing row, per
|
|
57
|
+
// v_team_game_points — a plain SUM(value * points_per_unit)).
|
|
58
|
+
assert.equal(homeRows.has('dst_sacks'), false);
|
|
59
|
+
assert.equal(homeRows.has('dst_takeaways'), false);
|
|
60
|
+
assert.equal(homeRows.has('two_point_conversions'), false);
|
|
61
|
+
assert.equal(homeRows.has('dst_tds'), false);
|
|
62
|
+
assert.equal(homeRows.has('dst_safeties'), false);
|
|
63
|
+
|
|
64
|
+
// Tiered categories: ALWAYS present even at 0 — this is exactly the shutout case
|
|
65
|
+
// where 0 is the seed ruleset's highest-value bucket (10 pt bonus), not a no-op.
|
|
66
|
+
// Dropping these would make v_fantasy_slot_points score the shutout as 0 via its
|
|
67
|
+
// COALESCE fallback instead of the correct bonus.
|
|
68
|
+
assert.equal(homeRows.get('dst_yards_allowed'), 0);
|
|
69
|
+
assert.equal(homeRows.get('dst_points_allowed'), 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('nonzero linear categories are emitted normally alongside the always-on tiered ones', async () => {
|
|
73
|
+
const { franchise, indexTeamsByTeamIndex, game } = buildGame({
|
|
74
|
+
homeScore: 17,
|
|
75
|
+
awayScore: 24,
|
|
76
|
+
homeStats: { SACKS: 2, TAKEAWAYS: 1, TWOPOINTCONVMADE: 1, DEFPASSYARDS: 200, DEFRUSHYARDS: 80 },
|
|
77
|
+
awayStats: { SACKS: 0, TAKEAWAYS: 0, TWOPOINTCONVMADE: 0, DEFPASSYARDS: 0, DEFRUSHYARDS: 0 },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const rows = await extractTeamGameStats(franchise, {
|
|
81
|
+
games: [game],
|
|
82
|
+
playerGameStatsByGameAndTeam: new Map(),
|
|
83
|
+
indexTeamsByTeamIndex,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const homeRows = new Map(rows.filter((r) => r.externalTeamId === 10).map((r) => [r.statCategoryKey, r.value]));
|
|
87
|
+
assert.equal(homeRows.get('dst_sacks'), 2);
|
|
88
|
+
assert.equal(homeRows.get('dst_takeaways'), 1);
|
|
89
|
+
assert.equal(homeRows.get('two_point_conversions'), 1);
|
|
90
|
+
assert.equal(homeRows.get('dst_yards_allowed'), 280);
|
|
91
|
+
assert.equal(homeRows.get('dst_points_allowed'), 24);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('dst_tds / dst_safeties are summed from player stats and zero-skipped like other linear categories', async () => {
|
|
95
|
+
const { franchise, indexTeamsByTeamIndex, game } = buildGame({
|
|
96
|
+
homeScore: 10,
|
|
97
|
+
awayScore: 7,
|
|
98
|
+
homeStats: { SACKS: 0, TAKEAWAYS: 0, TWOPOINTCONVMADE: 0, DEFPASSYARDS: 100, DEFRUSHYARDS: 50 },
|
|
99
|
+
awayStats: { SACKS: 0, TAKEAWAYS: 0, TWOPOINTCONVMADE: 0, DEFPASSYARDS: 100, DEFRUSHYARDS: 50 },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const playerGameStatsByGameAndTeam = new Map([
|
|
103
|
+
['RegularSeason:0:10:20:10', [{ statCategoryKey: 'def_int_tds', value: 1 }, { statCategoryKey: 'def_safeties', value: 1 }]],
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const rows = await extractTeamGameStats(franchise, {
|
|
107
|
+
games: [game],
|
|
108
|
+
playerGameStatsByGameAndTeam,
|
|
109
|
+
indexTeamsByTeamIndex,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const homeRows = new Map(rows.filter((r) => r.externalTeamId === 10).map((r) => [r.statCategoryKey, r.value]));
|
|
113
|
+
const awayRows = new Map(rows.filter((r) => r.externalTeamId === 20).map((r) => [r.statCategoryKey, r.value]));
|
|
114
|
+
|
|
115
|
+
assert.equal(homeRows.get('dst_tds'), 1);
|
|
116
|
+
assert.equal(homeRows.get('dst_safeties'), 1);
|
|
117
|
+
assert.equal(awayRows.has('dst_tds'), false, 'away team had no defensive TDs this game — skipped');
|
|
118
|
+
assert.equal(awayRows.has('dst_safeties'), false);
|
|
119
|
+
});
|