madden-fantasy-extractor 0.2.0 → 0.4.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/package.json +1 -1
- package/src/extract/franchise-news.js +114 -0
- package/src/extract/league-awards.js +137 -0
- package/src/extract/payload.js +19 -3
- package/src/extract/players.js +16 -0
- package/src/extract/teams.js +32 -1
- package/src/lib/franchise-table.js +19 -0
- package/test/franchise-news.test.js +145 -0
- package/test/franchise-table.test.js +37 -0
- package/test/league-awards.test.js +331 -0
- package/test/players.test.js +44 -0
- package/test/teams.test.js +79 -0
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Extracts franchise news: the `Story` and `Tweet` save tables, normalized into a
|
|
2
|
+
// single row shape. Stories resolve to a team via a direct `Team` reference field;
|
|
3
|
+
// Tweets resolve to a player by decoding `OverlayData`'s (TeamIndex, JerseyNum) pair
|
|
4
|
+
// and looking it up against the already-extracted player roster.
|
|
5
|
+
//
|
|
6
|
+
// The (TeamIndex, JerseyNum) resolution below is not a heuristic invented for this
|
|
7
|
+
// module -- it was empirically discovered and verified by decoding real Tweet.OverlayData
|
|
8
|
+
// against three real Madden saves spanning a season, achieving a 100% hit rate for real
|
|
9
|
+
// rostered players (TeamIndex 0-31). See docs/extraction-findings.md for the design
|
|
10
|
+
// investigation this plan's spec was built from.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { getTableByNameAndCapacity, liveRecords, resolveFieldRef } from '../lib/franchise-table.js';
|
|
14
|
+
|
|
15
|
+
// TeamIndex 32 is the FA/practice-squad pool, not a real team (see games.js's own
|
|
16
|
+
// TeamIndex-lookup pattern and db/schema.sql's sim_team comment). Tweet.OverlayData's
|
|
17
|
+
// (TeamIndex, JerseyNum) pair only resolves to a specific player for REAL rostered
|
|
18
|
+
// teams -- TeamIndex 32 rows use a jersey-number sentinel (observed as 10 across many
|
|
19
|
+
// unrelated named players in every real save checked) for generic practice-squad-
|
|
20
|
+
// flavor tweets that aren't actually tied to a resolvable individual. Emit
|
|
21
|
+
// externalPlayerId: null for these rather than guessing; do not attempt name-based
|
|
22
|
+
// disambiguation, which was tested against real saves and does not resolve them (the
|
|
23
|
+
// candidate pool stays large and unrelated to the tweet's named player).
|
|
24
|
+
const UNRESOLVABLE_TEAM_INDEX = 32;
|
|
25
|
+
|
|
26
|
+
function parseOverlayData(value) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const part of (value ?? '').split('|')) {
|
|
29
|
+
if (!part) continue;
|
|
30
|
+
const idx = part.indexOf(':');
|
|
31
|
+
if (idx === -1) continue;
|
|
32
|
+
out[part.slice(0, idx)] = part.slice(idx + 1);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function extractFranchiseNews(franchise, { indexTeamsByTeamIndex, indexPlayersByTeamAndJersey }) {
|
|
38
|
+
const stories = await extractStories(franchise, indexTeamsByTeamIndex);
|
|
39
|
+
const tweets = await extractTweets(franchise, indexPlayersByTeamAndJersey);
|
|
40
|
+
return [...stories, ...tweets];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function extractStories(franchise, indexTeamsByTeamIndex) {
|
|
44
|
+
const table = await getTableByNameAndCapacity(franchise, 'Story');
|
|
45
|
+
const records = liveRecords(table).filter((r) => r.Header || r.Tag);
|
|
46
|
+
|
|
47
|
+
const rows = [];
|
|
48
|
+
for (const r of records) {
|
|
49
|
+
const teamRecord = await resolveFieldRef(franchise, r, 'Team');
|
|
50
|
+
const team = teamRecord ? indexTeamsByTeamIndex.get(teamRecord.TeamIndex) : null;
|
|
51
|
+
|
|
52
|
+
const row = {
|
|
53
|
+
type: 'story',
|
|
54
|
+
header: r.Header || null,
|
|
55
|
+
text: r.Tag || '',
|
|
56
|
+
externalPlayerId: null,
|
|
57
|
+
externalTeamId: team?.externalTeamId ?? null,
|
|
58
|
+
isBreaking: Boolean(r.IsBreaking),
|
|
59
|
+
seasonYear: r.SeasonYear,
|
|
60
|
+
seasonWeekIndex: r.SeasonWeek,
|
|
61
|
+
};
|
|
62
|
+
row.dedupeKey = computeDedupeKey(row);
|
|
63
|
+
rows.push(row);
|
|
64
|
+
}
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function extractTweets(franchise, indexPlayersByTeamAndJersey) {
|
|
69
|
+
const table = await getTableByNameAndCapacity(franchise, 'Tweet');
|
|
70
|
+
const records = liveRecords(table).filter((r) => r.Tweet);
|
|
71
|
+
|
|
72
|
+
const rows = [];
|
|
73
|
+
for (const r of records) {
|
|
74
|
+
const overlay = parseOverlayData(r.OverlayData);
|
|
75
|
+
const teamIndex = overlay.ptlg != null ? Number(overlay.ptlg) : null;
|
|
76
|
+
const jerseyNum = overlay.pnum != null ? Number(overlay.pnum) : null;
|
|
77
|
+
|
|
78
|
+
let externalPlayerId = null;
|
|
79
|
+
if (teamIndex != null && teamIndex !== UNRESOLVABLE_TEAM_INDEX && jerseyNum != null) {
|
|
80
|
+
const candidates = indexPlayersByTeamAndJersey.get(`${teamIndex}|${jerseyNum}`) ?? [];
|
|
81
|
+
// A clean (TeamIndex, JerseyNum) hit is expected to be unique for real rosters
|
|
82
|
+
// (confirmed across 3 real saves) -- if more than one player somehow shares the
|
|
83
|
+
// key (a stale/retired duplicate), fall back to null rather than guess.
|
|
84
|
+
if (candidates.length === 1) externalPlayerId = candidates[0].externalPlayerId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const row = {
|
|
88
|
+
type: 'tweet',
|
|
89
|
+
header: null,
|
|
90
|
+
text: r.Tweet,
|
|
91
|
+
externalPlayerId,
|
|
92
|
+
externalTeamId: null,
|
|
93
|
+
isBreaking: Boolean(r.IsBreaking),
|
|
94
|
+
seasonYear: r.SeasonYear,
|
|
95
|
+
seasonWeekIndex: r.SeasonWeek,
|
|
96
|
+
};
|
|
97
|
+
row.dedupeKey = computeDedupeKey(row);
|
|
98
|
+
rows.push(row);
|
|
99
|
+
}
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function computeDedupeKey(row) {
|
|
104
|
+
const parts = [
|
|
105
|
+
row.type,
|
|
106
|
+
row.header,
|
|
107
|
+
row.text,
|
|
108
|
+
row.externalPlayerId,
|
|
109
|
+
row.externalTeamId,
|
|
110
|
+
row.seasonYear,
|
|
111
|
+
row.seasonWeekIndex,
|
|
112
|
+
];
|
|
113
|
+
return createHash('sha256').update(parts.join('|')).digest('hex');
|
|
114
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { getTableByNameAndCapacity, liveRecords, resolveFieldRef, resolveWrapperSlots } from '../lib/franchise-table.js';
|
|
2
|
+
|
|
3
|
+
// League.Awards.WeeklyAwards -- confirmed live, real, per-franchise data (AFC/NFC
|
|
4
|
+
// Offensive/Defensive Player of the Week) in every real save checked.
|
|
5
|
+
//
|
|
6
|
+
// League.Awards.AnnualAwards -- a 720-slot array mixing frozen real-world historical
|
|
7
|
+
// placeholders (AwardScore always 0, PeriodIndex a real calendar year) with every
|
|
8
|
+
// completed franchise season's real, live-computed values (AwardScore > 0). Confirmed
|
|
9
|
+
// via real-save investigation (docs/superpowers/investigations/2026-07-25-league-
|
|
10
|
+
// history-award-grouping-verification.md) that PeriodIndex grows forward
|
|
11
|
+
// chronologically from a fixed anchor as more seasons complete -- PeriodIndex=0 is
|
|
12
|
+
// only the FIRST season this array ever captured, not "the current season" forever.
|
|
13
|
+
// Restricting to PeriodIndex===0 would silently miss every season after that first
|
|
14
|
+
// one, so the real discriminator between "live season data" and "frozen historical
|
|
15
|
+
// placeholder" is AwardScore > 0 alone. Confirmed empirically that a season's
|
|
16
|
+
// PeriodIndex entries are ABSENT while that season is still in progress and only
|
|
17
|
+
// appear once it fully closes -- this is expected, not a bug; a mid-season
|
|
18
|
+
// extraction run correctly produces zero AnnualAwards rows for that season.
|
|
19
|
+
//
|
|
20
|
+
// A given AwardType can have MULTIPLE winners for the same PeriodIndex (confirmed:
|
|
21
|
+
// AFC/NFC-style splits on most categories) -- do not assume or enforce one winner per
|
|
22
|
+
// type per season.
|
|
23
|
+
//
|
|
24
|
+
// AllTimeAwards and LastSeasonWeeklyAwards were investigated and show the same
|
|
25
|
+
// frozen/historical pattern as the debunked historical AnnualAwards entries in every
|
|
26
|
+
// save checked -- deliberately not extracted.
|
|
27
|
+
//
|
|
28
|
+
// League.Awards has no Coach_of_Year slot at all -- that award is sourced from a
|
|
29
|
+
// completely different, flat (non-wrapper) table, LeagueHistoryAward, resolved
|
|
30
|
+
// directly off the franchise rather than through League.Awards. See
|
|
31
|
+
// extractCoachOfYearAwards below.
|
|
32
|
+
export async function extractLeagueAwards(franchise, { indexTeamsByTeamIndex }) {
|
|
33
|
+
const leagueTable = await getTableByNameAndCapacity(franchise, 'League');
|
|
34
|
+
const leagueRecord = liveRecords(leagueTable)[0];
|
|
35
|
+
if (!leagueRecord) return [];
|
|
36
|
+
|
|
37
|
+
const awardsRecord = await resolveFieldRef(franchise, leagueRecord, 'Awards');
|
|
38
|
+
if (!awardsRecord) return [];
|
|
39
|
+
|
|
40
|
+
const weekly = await extractAwardWrapper(franchise, awardsRecord, 'WeeklyAwards', indexTeamsByTeamIndex, { excludeHistoricalPlaceholders: false });
|
|
41
|
+
const annual = await extractAwardWrapper(franchise, awardsRecord, 'AnnualAwards', indexTeamsByTeamIndex, { excludeHistoricalPlaceholders: true });
|
|
42
|
+
const coachOfYear = await extractCoachOfYearAwards(franchise);
|
|
43
|
+
return [...weekly, ...annual, ...coachOfYear];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function extractAwardWrapper(franchise, awardsRecord, fieldKey, indexTeamsByTeamIndex, { excludeHistoricalPlaceholders }) {
|
|
47
|
+
const wrapper = await resolveFieldRef(franchise, awardsRecord, fieldKey);
|
|
48
|
+
if (!wrapper) return [];
|
|
49
|
+
|
|
50
|
+
const awardRecords = await resolveWrapperSlots(franchise, wrapper, 'Award');
|
|
51
|
+
const rows = [];
|
|
52
|
+
for (const r of awardRecords) {
|
|
53
|
+
// AnnualAwards mixes frozen historical placeholders (real year, AwardScore 0)
|
|
54
|
+
// with every completed season's real values (AwardScore>0, whatever its
|
|
55
|
+
// PeriodIndex) -- only extract the latter for that array. WeeklyAwards has no
|
|
56
|
+
// equivalent historical pollution (confirmed in real saves), so this filter is a
|
|
57
|
+
// no-op there.
|
|
58
|
+
if (excludeHistoricalPlaceholders && !(r.AwardScore > 0)) continue;
|
|
59
|
+
|
|
60
|
+
const playerRecord = await resolveFieldRef(franchise, r, 'Player');
|
|
61
|
+
if (!playerRecord) continue; // unresolvable player ref -- skip, matching transactions.js's convention
|
|
62
|
+
|
|
63
|
+
const teamRecord = await resolveFieldRef(franchise, r, 'Team');
|
|
64
|
+
const team = teamRecord ? indexTeamsByTeamIndex.get(teamRecord.TeamIndex) : null;
|
|
65
|
+
|
|
66
|
+
rows.push({
|
|
67
|
+
awardType: r.AwardType,
|
|
68
|
+
period: r.Period,
|
|
69
|
+
periodIndex: r.PeriodIndex,
|
|
70
|
+
awardScore: r.AwardScore,
|
|
71
|
+
externalPlayerId: playerRecord.PresentationId,
|
|
72
|
+
externalTeamId: team?.externalTeamId ?? null,
|
|
73
|
+
conference: r.Conference ?? null,
|
|
74
|
+
coachName: null,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return rows;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// LeagueHistoryAward -- a flat (non-wrapper), un-marked table with no per-row
|
|
81
|
+
// season/period field of its own. Confirmed via real-save investigation (see
|
|
82
|
+
// docs/superpowers/investigations/2026-07-25-league-history-award-grouping-
|
|
83
|
+
// verification.md against CAREER-2028) that its live records partition into clean,
|
|
84
|
+
// consecutive 7-row blocks in chronological order (block 0 = oldest season), and
|
|
85
|
+
// block N corresponds exactly to League.Awards.AnnualAwards' PeriodIndex=N (verified
|
|
86
|
+
// via exact MVP-name cross-match across 3 blocks, including a repeated-winner case
|
|
87
|
+
// that rules out coincidental matching).
|
|
88
|
+
//
|
|
89
|
+
// Every block's 7 AwardType values are pairwise distinct, and matched here by each
|
|
90
|
+
// row's own AwardType field (not fixed sub-position within the block) -- more robust
|
|
91
|
+
// to a future save where a block's internal order might differ.
|
|
92
|
+
//
|
|
93
|
+
// CRITICAL: every block's first row reads AwardType "INVALID" -- confirmed REAL,
|
|
94
|
+
// populated per-season data (a genuine distinct player+position each season, e.g.
|
|
95
|
+
// Josh Allen/QB, Keisean Nixon/CB, Trevor Lawrence/QB across 3 real blocks), not an
|
|
96
|
+
// empty/placeholder row. It is NOT one of the 6 award types this project needs, and
|
|
97
|
+
// is deliberately excluded here rather than surfaced or interpreted.
|
|
98
|
+
//
|
|
99
|
+
// Coach_of_Year rows have flat firstName/lastName/Position text fields and no
|
|
100
|
+
// resolvable player/coach reference of any kind -- Position reads as the literal
|
|
101
|
+
// string "Invalid_" for these rows (expected: a coach has no position, not an error).
|
|
102
|
+
const LEAGUE_HISTORY_BLOCK_SIZE = 7; // confirmed via real-save investigation (see comment above)
|
|
103
|
+
|
|
104
|
+
async function extractCoachOfYearAwards(franchise) {
|
|
105
|
+
const table = await getTableByNameAndCapacity(franchise, 'LeagueHistoryAward');
|
|
106
|
+
const records = liveRecords(table);
|
|
107
|
+
|
|
108
|
+
if (records.length % LEAGUE_HISTORY_BLOCK_SIZE !== 0) {
|
|
109
|
+
// Don't let an unproven assumption about ONE table's row count (confirmed only at
|
|
110
|
+
// n=1 real save so far -- see the block-grouping investigation) abort the entire
|
|
111
|
+
// payload. Skip just the Coach_of_Year awards for this run; everything else
|
|
112
|
+
// (weekly/annual awards, stats, games, transactions) still extracts normally.
|
|
113
|
+
console.error(
|
|
114
|
+
`LeagueHistoryAward has ${records.length} live records, not a multiple of ${LEAGUE_HISTORY_BLOCK_SIZE} -- ` +
|
|
115
|
+
'the block-size assumption (see the league-history-award-grouping-verification investigation) may no longer hold for this save. Skipping Coach_of_Year extraction for this run.',
|
|
116
|
+
);
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (let block = 0; block < records.length / LEAGUE_HISTORY_BLOCK_SIZE; block++) {
|
|
122
|
+
const blockRecords = records.slice(block * LEAGUE_HISTORY_BLOCK_SIZE, (block + 1) * LEAGUE_HISTORY_BLOCK_SIZE);
|
|
123
|
+
const coachRecord = blockRecords.find((r) => r.AwardType === 'Coach_of_Year');
|
|
124
|
+
if (!coachRecord) continue; // defensive -- shouldn't happen per the investigation's findings, but don't crash if it does
|
|
125
|
+
rows.push({
|
|
126
|
+
awardType: 'Coach_of_Year',
|
|
127
|
+
period: 'Season',
|
|
128
|
+
periodIndex: block, // block index IS the PeriodIndex, per the investigation's confirmed correspondence
|
|
129
|
+
awardScore: 0, // LeagueHistoryAward has no AwardScore field -- not meaningful for this row shape
|
|
130
|
+
externalPlayerId: null,
|
|
131
|
+
externalTeamId: null,
|
|
132
|
+
conference: null,
|
|
133
|
+
coachName: `${coachRecord.firstName} ${coachRecord.lastName}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
package/src/extract/payload.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// week, independent of which games' stats have already been captured).
|
|
12
12
|
|
|
13
13
|
import { extractClock } from './clock.js';
|
|
14
|
-
import { extractTeams, indexTeamsByTeamIndex } from './teams.js';
|
|
15
|
-
import { extractPlayers } from './players.js';
|
|
14
|
+
import { extractTeams, extractDivisionConferenceByTeam, indexTeamsByTeamIndex } from './teams.js';
|
|
15
|
+
import { extractPlayers, indexPlayersByTeamAndJersey } from './players.js';
|
|
16
16
|
import { extractGames, gameKey } from './games.js';
|
|
17
17
|
import { extractPlayerGameStats } from './player-game-stats.js';
|
|
18
18
|
import { extractTeamGameStats, indexPlayerGameStatsByGameAndTeam } from './team-game-stats.js';
|
|
@@ -20,13 +20,21 @@ import { extractPlayerWeekStatus } from './player-week-status.js';
|
|
|
20
20
|
import { extractTransactions } from './transactions.js';
|
|
21
21
|
import { extractCumulativeStats } from './cumulative-stats.js';
|
|
22
22
|
import { extractDepthChart } from './depth-chart.js';
|
|
23
|
+
import { extractFranchiseNews } from './franchise-news.js';
|
|
24
|
+
import { extractLeagueAwards } from './league-awards.js';
|
|
23
25
|
import { selectGamesToCapture, selectReadyGames, selectPendingGames, unionCapturedKeys } from '../lib/incremental.js';
|
|
24
26
|
|
|
25
|
-
const PAYLOAD_SCHEMA_VERSION = '1.
|
|
27
|
+
const PAYLOAD_SCHEMA_VERSION = '1.4.0';
|
|
26
28
|
|
|
27
29
|
export async function buildPayload(franchise, { toolVersion, fullMode = false, capturedGameKeys = new Set() }) {
|
|
28
30
|
const clock = await extractClock(franchise);
|
|
29
31
|
const teams = await extractTeams(franchise);
|
|
32
|
+
const divisionConferenceByTeam = await extractDivisionConferenceByTeam(franchise);
|
|
33
|
+
for (const team of teams) {
|
|
34
|
+
const dc = divisionConferenceByTeam.get(team.externalTeamId);
|
|
35
|
+
team.divisionName = dc?.divisionName ?? null;
|
|
36
|
+
team.conferenceName = dc?.conferenceName ?? null;
|
|
37
|
+
}
|
|
30
38
|
const teamIdx = indexTeamsByTeamIndex(teams);
|
|
31
39
|
|
|
32
40
|
const allGames = await extractGames(franchise, { indexTeamsByTeamIndex: teamIdx });
|
|
@@ -39,6 +47,7 @@ export async function buildPayload(franchise, { toolVersion, fullMode = false, c
|
|
|
39
47
|
const gamesByKey = new Map(gamesToCapture.map((g) => [gameKey(g), g]));
|
|
40
48
|
|
|
41
49
|
const players = await extractPlayers(franchise, { currentSeasonYear: clock.calendarYear });
|
|
50
|
+
const playerIdx = indexPlayersByTeamAndJersey(players);
|
|
42
51
|
|
|
43
52
|
const playerGameStats = await extractPlayerGameStats(franchise, {
|
|
44
53
|
players,
|
|
@@ -66,6 +75,11 @@ export async function buildPayload(franchise, { toolVersion, fullMode = false, c
|
|
|
66
75
|
const transactions = await extractTransactions(franchise, { indexTeamsByTeamIndex: teamIdx });
|
|
67
76
|
const cumulativeStats = await extractCumulativeStats(franchise, { players });
|
|
68
77
|
const depthChart = await extractDepthChart(franchise, { teams });
|
|
78
|
+
const franchiseNews = await extractFranchiseNews(franchise, {
|
|
79
|
+
indexTeamsByTeamIndex: teamIdx,
|
|
80
|
+
indexPlayersByTeamAndJersey: playerIdx,
|
|
81
|
+
});
|
|
82
|
+
const franchiseAwards = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: teamIdx });
|
|
69
83
|
|
|
70
84
|
// Only games that ACTUALLY produced player-level stats this run get added to the
|
|
71
85
|
// watermark (PreSeason excepted — see incremental.js's selectReadyGames) — "decided"
|
|
@@ -101,6 +115,8 @@ export async function buildPayload(franchise, { toolVersion, fullMode = false, c
|
|
|
101
115
|
transactions,
|
|
102
116
|
cumulativeStats,
|
|
103
117
|
depthChart,
|
|
118
|
+
franchiseNews,
|
|
119
|
+
franchiseAwards,
|
|
104
120
|
};
|
|
105
121
|
|
|
106
122
|
const nextCapturedGameKeys = unionCapturedKeys(capturedGameKeys, readyGames, clock.seasonIndex);
|
package/src/extract/players.js
CHANGED
|
@@ -34,6 +34,7 @@ export async function extractPlayers(franchise, { currentSeasonYear }) {
|
|
|
34
34
|
// carried through for downstream extraction (team membership, snapshot, stats)
|
|
35
35
|
// without re-scanning the Player table — not part of the `player` table shape.
|
|
36
36
|
teamIndex: r.TeamIndex,
|
|
37
|
+
jerseyNum: r.JerseyNum,
|
|
37
38
|
contractStatus: r.ContractStatus,
|
|
38
39
|
|
|
39
40
|
snapshot: extractSnapshot(r),
|
|
@@ -72,3 +73,18 @@ function extractRatings(r) {
|
|
|
72
73
|
}
|
|
73
74
|
return ratings;
|
|
74
75
|
}
|
|
76
|
+
|
|
77
|
+
// Groups already-extracted players by (teamIndex, jerseyNum), the key franchise-news
|
|
78
|
+
// extraction (Task 8) uses to resolve a Story/Tweet's team+jersey reference back to a
|
|
79
|
+
// specific player. Multiple players can share a key (e.g. TeamIndex 32 = free agents/
|
|
80
|
+
// practice squad overflow, or a mid-season jersey number reuse) — callers must handle
|
|
81
|
+
// arrays with more than one entry, not assume a unique match.
|
|
82
|
+
export function indexPlayersByTeamAndJersey(players) {
|
|
83
|
+
const map = new Map();
|
|
84
|
+
for (const p of players) {
|
|
85
|
+
const key = `${p.teamIndex}|${p.jerseyNum}`;
|
|
86
|
+
if (!map.has(key)) map.set(key, []);
|
|
87
|
+
map.get(key).push(p);
|
|
88
|
+
}
|
|
89
|
+
return map;
|
|
90
|
+
}
|
package/src/extract/teams.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the real 32 teams live in the capacity-37 instance, filtered to TEAM_TYPE='Current'.
|
|
5
5
|
// TeamIndex 32 is not a team, it's the FA/practice pool; we never create a row for it.
|
|
6
6
|
|
|
7
|
-
import { getTableByNameAndCapacity, liveRecords } from '../lib/franchise-table.js';
|
|
7
|
+
import { getTableByNameAndCapacity, getAllLiveRecordsAcrossInstances, liveRecords, resolveFieldRef, resolveWrapperSlots } from '../lib/franchise-table.js';
|
|
8
8
|
|
|
9
9
|
export async function extractTeams(franchise) {
|
|
10
10
|
const table = await getTableByNameAndCapacity(franchise, 'Team');
|
|
@@ -35,6 +35,37 @@ export async function extractTeams(franchise) {
|
|
|
35
35
|
}));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Division/conference membership is NOT a field on Team -- it's the reverse
|
|
40
|
+
* relationship (Conference -> Divisions -> Teams), confirmed via real-save
|
|
41
|
+
* investigation (see docs/superpowers/specs/2026-07-26-conference-division-standings-
|
|
42
|
+
* design.md in the madden-fantasy repo). Conference itself exists as TWO SEPARATE
|
|
43
|
+
* table instances in the save (one AFC, one NFC), which is why this reads via
|
|
44
|
+
* getAllLiveRecordsAcrossInstances rather than getTableByNameAndCapacity -- the
|
|
45
|
+
* latter would silently return only one conference.
|
|
46
|
+
*
|
|
47
|
+
* Returns a lookup from externalTeamId (Team.PresentationId) to
|
|
48
|
+
* { divisionName, conferenceName }.
|
|
49
|
+
*/
|
|
50
|
+
export async function extractDivisionConferenceByTeam(franchise) {
|
|
51
|
+
const conferences = await getAllLiveRecordsAcrossInstances(franchise, 'Conference');
|
|
52
|
+
const result = new Map();
|
|
53
|
+
for (const conf of conferences) {
|
|
54
|
+
const divWrapper = await resolveFieldRef(franchise, conf, 'Divisions');
|
|
55
|
+
if (!divWrapper) continue;
|
|
56
|
+
const divisions = await resolveWrapperSlots(franchise, divWrapper, 'Division');
|
|
57
|
+
for (const div of divisions) {
|
|
58
|
+
const teamWrapper = await resolveFieldRef(franchise, div, 'Teams');
|
|
59
|
+
if (!teamWrapper) continue;
|
|
60
|
+
const teams = await resolveWrapperSlots(franchise, teamWrapper, 'Team');
|
|
61
|
+
for (const team of teams) {
|
|
62
|
+
result.set(team.PresentationId, { divisionName: div.Name, conferenceName: conf.Name });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
/** TeamIndex (0..31) -> extracted team row, for resolving Player.TeamIndex and SeasonGame refs. */
|
|
39
70
|
export function indexTeamsByTeamIndex(teams) {
|
|
40
71
|
return new Map(teams.map((t) => [t.teamIndex, t]));
|
|
@@ -29,6 +29,25 @@ export async function getTableByNameAndCapacity(franchise, name, opts) {
|
|
|
29
29
|
return table;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Some tables exist as multiple real (non-decoy) instances rather than one real
|
|
34
|
+
* instance plus decoys -- confirmed for `Conference` (two capacity-1 instances, one
|
|
35
|
+
* holding AFC, one holding NFC; see docs/superpowers/specs/2026-07-26-conference-
|
|
36
|
+
* division-standings-design.md in the madden-fantasy repo). Unlike
|
|
37
|
+
* getTableByNameAndCapacity, this reads and returns every instance's live records
|
|
38
|
+
* merged into one array, rather than picking a single "best" instance.
|
|
39
|
+
*/
|
|
40
|
+
export async function getAllLiveRecordsAcrossInstances(franchise, name) {
|
|
41
|
+
const matches = franchise.tables.filter((t) => t.name === name);
|
|
42
|
+
if (matches.length === 0) throw new Error(`No table named "${name}" in this save`);
|
|
43
|
+
const allRecords = [];
|
|
44
|
+
for (const table of matches) {
|
|
45
|
+
await table.readRecords();
|
|
46
|
+
allRecords.push(...liveRecords(table));
|
|
47
|
+
}
|
|
48
|
+
return allRecords;
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* Resolve a reference field to its target record, reading the target table first if needed.
|
|
34
53
|
*
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractFranchiseNews } from '../src/extract/franchise-news.js';
|
|
4
|
+
import { indexPlayersByTeamAndJersey } from '../src/extract/players.js';
|
|
5
|
+
import { indexTeamsByTeamIndex } from '../src/extract/teams.js';
|
|
6
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
7
|
+
|
|
8
|
+
test('extractFranchiseNews: resolves a Tweet with valid OverlayData to the correct player', async () => {
|
|
9
|
+
const teams = [{ teamIndex: 5, externalTeamId: 50 }];
|
|
10
|
+
const players = [{ teamIndex: 5, jerseyNum: 12, externalPlayerId: 500 }];
|
|
11
|
+
|
|
12
|
+
const tweetRecord = fakeRecord({
|
|
13
|
+
Tweet: 'Big performance from the star player.',
|
|
14
|
+
OverlayData: 'pnm:Test Player|ptlg:5|pnum:12|',
|
|
15
|
+
IsBreaking: true,
|
|
16
|
+
SeasonYear: 2026,
|
|
17
|
+
SeasonWeek: 3,
|
|
18
|
+
});
|
|
19
|
+
const tweetTable = fakeTable('Tweet', [tweetRecord]);
|
|
20
|
+
const storyTable = fakeTable('Story', []);
|
|
21
|
+
const franchise = fakeFranchise([tweetTable, storyTable]);
|
|
22
|
+
|
|
23
|
+
const rows = await extractFranchiseNews(franchise, {
|
|
24
|
+
indexTeamsByTeamIndex: new Map([[5, teams[0]]]),
|
|
25
|
+
indexPlayersByTeamAndJersey: indexPlayersByTeamAndJersey(players),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const tweetRows = rows.filter((r) => r.type === 'tweet');
|
|
29
|
+
assert.equal(tweetRows.length, 1);
|
|
30
|
+
assert.equal(tweetRows[0].externalPlayerId, 500);
|
|
31
|
+
assert.equal(tweetRows[0].text, 'Big performance from the star player.');
|
|
32
|
+
assert.equal(tweetRows[0].isBreaking, true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('extractFranchiseNews: a TeamIndex=32 Tweet resolves externalPlayerId to null, never guesses by name', async () => {
|
|
36
|
+
const players = [{ teamIndex: 10, jerseyNum: 10, externalPlayerId: 999 }]; // unrelated real player, same jersey number coincidentally
|
|
37
|
+
|
|
38
|
+
const tweetRecord = fakeRecord({
|
|
39
|
+
Tweet: 'Generic practice-squad tweet.',
|
|
40
|
+
OverlayData: 'pnm:Some Practice Player|ptlg:32|pnum:10|',
|
|
41
|
+
IsBreaking: false,
|
|
42
|
+
SeasonYear: 2026,
|
|
43
|
+
SeasonWeek: 3,
|
|
44
|
+
});
|
|
45
|
+
const tweetTable = fakeTable('Tweet', [tweetRecord]);
|
|
46
|
+
const storyTable = fakeTable('Story', []);
|
|
47
|
+
const franchise = fakeFranchise([tweetTable, storyTable]);
|
|
48
|
+
|
|
49
|
+
const rows = await extractFranchiseNews(franchise, {
|
|
50
|
+
indexTeamsByTeamIndex: new Map(),
|
|
51
|
+
indexPlayersByTeamAndJersey: indexPlayersByTeamAndJersey(players),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.equal(rows[0].externalPlayerId, null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('extractFranchiseNews: a (TeamIndex, JerseyNum) collision (2+ candidates) resolves to null, not a guess', async () => {
|
|
58
|
+
const players = [
|
|
59
|
+
{ teamIndex: 5, jerseyNum: 12, externalPlayerId: 500 },
|
|
60
|
+
{ teamIndex: 5, jerseyNum: 12, externalPlayerId: 501 }, // duplicate key, e.g. a stale entry
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const tweetRecord = fakeRecord({
|
|
64
|
+
Tweet: 'Ambiguous tweet.',
|
|
65
|
+
OverlayData: 'pnm:Test Player|ptlg:5|pnum:12|',
|
|
66
|
+
IsBreaking: false,
|
|
67
|
+
SeasonYear: 2026,
|
|
68
|
+
SeasonWeek: 3,
|
|
69
|
+
});
|
|
70
|
+
const tweetTable = fakeTable('Tweet', [tweetRecord]);
|
|
71
|
+
const storyTable = fakeTable('Story', []);
|
|
72
|
+
const franchise = fakeFranchise([tweetTable, storyTable]);
|
|
73
|
+
|
|
74
|
+
const rows = await extractFranchiseNews(franchise, {
|
|
75
|
+
indexTeamsByTeamIndex: new Map(),
|
|
76
|
+
indexPlayersByTeamAndJersey: indexPlayersByTeamAndJersey(players),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.equal(rows[0].externalPlayerId, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('extractFranchiseNews: resolves a Story to its team, never a player', async () => {
|
|
83
|
+
const teamRecord = fakeRecord({ TeamIndex: 7 });
|
|
84
|
+
const teamTable = fakeTable('Team', [teamRecord]);
|
|
85
|
+
|
|
86
|
+
const storyRecord = fakeRecord(
|
|
87
|
+
{ Header: 'Big Win', Tag: 'The team won big.', IsBreaking: true, SeasonYear: 2026, SeasonWeek: 4 },
|
|
88
|
+
{ Team: { tableId: teamTable._id, rowNumber: 0 } },
|
|
89
|
+
);
|
|
90
|
+
const storyTable = fakeTable('Story', [storyRecord]);
|
|
91
|
+
const tweetTable = fakeTable('Tweet', []);
|
|
92
|
+
const franchise = fakeFranchise([storyTable, tweetTable, teamTable]);
|
|
93
|
+
|
|
94
|
+
const rows = await extractFranchiseNews(franchise, {
|
|
95
|
+
indexTeamsByTeamIndex: new Map([[7, { teamIndex: 7, externalTeamId: 70 }]]),
|
|
96
|
+
indexPlayersByTeamAndJersey: new Map(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const storyRows = rows.filter((r) => r.type === 'story');
|
|
100
|
+
assert.equal(storyRows.length, 1);
|
|
101
|
+
assert.equal(storyRows[0].externalPlayerId, null);
|
|
102
|
+
assert.equal(storyRows[0].externalTeamId, 70);
|
|
103
|
+
assert.equal(storyRows[0].header, 'Big Win');
|
|
104
|
+
assert.equal(storyRows[0].text, 'The team won big.');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('extractFranchiseNews: dedupeKey is stable for identical content and differs for different content', async () => {
|
|
108
|
+
const tweetRecordA = fakeRecord({ Tweet: 'Same text.', OverlayData: '', IsBreaking: false, SeasonYear: 2026, SeasonWeek: 1 });
|
|
109
|
+
const tweetRecordB = fakeRecord({ Tweet: 'Same text.', OverlayData: '', IsBreaking: false, SeasonYear: 2026, SeasonWeek: 1 });
|
|
110
|
+
const tweetRecordC = fakeRecord({ Tweet: 'Different text.', OverlayData: '', IsBreaking: false, SeasonYear: 2026, SeasonWeek: 1 });
|
|
111
|
+
|
|
112
|
+
async function run(record) {
|
|
113
|
+
const tweetTable = fakeTable('Tweet', [record]);
|
|
114
|
+
const storyTable = fakeTable('Story', []);
|
|
115
|
+
const franchise = fakeFranchise([tweetTable, storyTable]);
|
|
116
|
+
return extractFranchiseNews(franchise, { indexTeamsByTeamIndex: new Map(), indexPlayersByTeamAndJersey: new Map() });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [rowsA] = [await run(tweetRecordA)];
|
|
120
|
+
const [rowsB] = [await run(tweetRecordB)];
|
|
121
|
+
const [rowsC] = [await run(tweetRecordC)];
|
|
122
|
+
|
|
123
|
+
assert.equal(rowsA[0].dedupeKey, rowsB[0].dedupeKey);
|
|
124
|
+
assert.notEqual(rowsA[0].dedupeKey, rowsC[0].dedupeKey);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('extractFranchiseNews: dedupeKey differs for identical content in different weeks (prevents cross-week collisions)', async () => {
|
|
128
|
+
const tweetRecordWeek1 = fakeRecord({ Tweet: 'Same text.', OverlayData: '', IsBreaking: false, SeasonYear: 2026, SeasonWeek: 1 });
|
|
129
|
+
const tweetRecordWeek2 = fakeRecord({ Tweet: 'Same text.', OverlayData: '', IsBreaking: false, SeasonYear: 2026, SeasonWeek: 2 });
|
|
130
|
+
const tweetRecordYear2027 = fakeRecord({ Tweet: 'Same text.', OverlayData: '', IsBreaking: false, SeasonYear: 2027, SeasonWeek: 1 });
|
|
131
|
+
|
|
132
|
+
async function run(record) {
|
|
133
|
+
const tweetTable = fakeTable('Tweet', [record]);
|
|
134
|
+
const storyTable = fakeTable('Story', []);
|
|
135
|
+
const franchise = fakeFranchise([tweetTable, storyTable]);
|
|
136
|
+
return extractFranchiseNews(franchise, { indexTeamsByTeamIndex: new Map(), indexPlayersByTeamAndJersey: new Map() });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rowsWeek1 = await run(tweetRecordWeek1);
|
|
140
|
+
const rowsWeek2 = await run(tweetRecordWeek2);
|
|
141
|
+
const rowsYear2027 = await run(tweetRecordYear2027);
|
|
142
|
+
|
|
143
|
+
assert.notEqual(rowsWeek1[0].dedupeKey, rowsWeek2[0].dedupeKey);
|
|
144
|
+
assert.notEqual(rowsWeek1[0].dedupeKey, rowsYear2027[0].dedupeKey);
|
|
145
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { getAllLiveRecordsAcrossInstances } from '../src/lib/franchise-table.js';
|
|
4
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
5
|
+
|
|
6
|
+
test('getAllLiveRecordsAcrossInstances: merges live records across multiple instances of the same table name', async () => {
|
|
7
|
+
const afcRecord = fakeRecord({ Name: 'AFC' });
|
|
8
|
+
const afcTable = fakeTable('Conference', [afcRecord]);
|
|
9
|
+
const nfcRecord = fakeRecord({ Name: 'NFC' });
|
|
10
|
+
const nfcTable = fakeTable('Conference', [nfcRecord]);
|
|
11
|
+
|
|
12
|
+
const franchise = fakeFranchise([afcTable, nfcTable]);
|
|
13
|
+
|
|
14
|
+
const records = await getAllLiveRecordsAcrossInstances(franchise, 'Conference');
|
|
15
|
+
assert.equal(records.length, 2);
|
|
16
|
+
assert.deepEqual(records.map((r) => r.Name).sort(), ['AFC', 'NFC']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('getAllLiveRecordsAcrossInstances: skips empty records within each instance', async () => {
|
|
20
|
+
const liveRecord = fakeRecord({ Name: 'AFC' });
|
|
21
|
+
const emptyRecord = { ...fakeRecord({}), isEmpty: true };
|
|
22
|
+
const table = fakeTable('Conference', [liveRecord, emptyRecord]);
|
|
23
|
+
|
|
24
|
+
const franchise = fakeFranchise([table]);
|
|
25
|
+
|
|
26
|
+
const records = await getAllLiveRecordsAcrossInstances(franchise, 'Conference');
|
|
27
|
+
assert.equal(records.length, 1);
|
|
28
|
+
assert.equal(records[0].Name, 'AFC');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('getAllLiveRecordsAcrossInstances: throws when no table with that name exists', async () => {
|
|
32
|
+
const franchise = fakeFranchise([]);
|
|
33
|
+
await assert.rejects(
|
|
34
|
+
() => getAllLiveRecordsAcrossInstances(franchise, 'Conference'),
|
|
35
|
+
/No table named "Conference"/,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractLeagueAwards } from '../src/extract/league-awards.js';
|
|
4
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
5
|
+
|
|
6
|
+
// League.Awards resolves through TWO levels of reference: League -> Awards ->
|
|
7
|
+
// WeeklyAwards/AnnualAwards (each a wrapper with numbered Award0/Award1/... slots,
|
|
8
|
+
// same shape as GameStats[]) -> individual Award/PlayerAward records. Build the fake
|
|
9
|
+
// table graph to match this real shape.
|
|
10
|
+
//
|
|
11
|
+
// LeagueHistoryAward (the Coach_of_Year source) is a separate, flat, non-wrapper
|
|
12
|
+
// table resolved directly by name/capacity, not through League.Awards -- tests that
|
|
13
|
+
// don't care about Coach_of_Year still need an empty LeagueHistoryAward table present
|
|
14
|
+
// so extractCoachOfYearAwards's getTableByNameAndCapacity lookup doesn't throw.
|
|
15
|
+
|
|
16
|
+
/** An empty LeagueHistoryAward table -- satisfies extractCoachOfYearAwards's lookup for tests that don't exercise it. */
|
|
17
|
+
function emptyLeagueHistoryAwardTable() {
|
|
18
|
+
return fakeTable('LeagueHistoryAward', []);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('extractLeagueAwards: extracts a WeeklyAwards entry with resolved player/team', async () => {
|
|
22
|
+
const player = fakeRecord({ PresentationId: 500, FirstName: 'Test', LastName: 'Player' });
|
|
23
|
+
const playerTable = fakeTable('Player', [player]);
|
|
24
|
+
const team = fakeRecord({ TeamIndex: 5 });
|
|
25
|
+
const teamTable = fakeTable('Team', [team]);
|
|
26
|
+
|
|
27
|
+
const awardRecord = fakeRecord(
|
|
28
|
+
{ AwardType: 'AFC_Offensive_Player_of_Week', Period: 'Week', PeriodIndex: 1, AwardScore: 0 },
|
|
29
|
+
{ Player: { tableId: playerTable._id, rowNumber: 0 }, Team: { tableId: teamTable._id, rowNumber: 0 } },
|
|
30
|
+
);
|
|
31
|
+
const awardTable = fakeTable('Award', [awardRecord]);
|
|
32
|
+
// Wrapper table: numbered slot fields pointing at the award record.
|
|
33
|
+
const weeklyWrapper = fakeRecord({}, { Award0: { tableId: awardTable._id, rowNumber: 0 } });
|
|
34
|
+
const weeklyWrapperTable = fakeTable('WeeklyAwardsWrapper', [weeklyWrapper]);
|
|
35
|
+
|
|
36
|
+
const annualWrapper = fakeRecord({}, {}); // empty AnnualAwards for this test
|
|
37
|
+
const annualWrapperTable = fakeTable('AnnualAwardsWrapper', [annualWrapper]);
|
|
38
|
+
|
|
39
|
+
const awardsRecord = fakeRecord(
|
|
40
|
+
{},
|
|
41
|
+
{
|
|
42
|
+
WeeklyAwards: { tableId: weeklyWrapperTable._id, rowNumber: 0 },
|
|
43
|
+
AnnualAwards: { tableId: annualWrapperTable._id, rowNumber: 0 },
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
const awardsTable = fakeTable('Awards', [awardsRecord]);
|
|
47
|
+
|
|
48
|
+
const leagueRecord = fakeRecord({}, { Awards: { tableId: awardsTable._id, rowNumber: 0 } });
|
|
49
|
+
const leagueTable = fakeTable('League', [leagueRecord]);
|
|
50
|
+
|
|
51
|
+
const franchise = fakeFranchise([playerTable, teamTable, awardTable, weeklyWrapperTable, annualWrapperTable, awardsTable, leagueTable, emptyLeagueHistoryAwardTable()]);
|
|
52
|
+
|
|
53
|
+
const rows = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: new Map([[5, { externalTeamId: 50, teamIndex: 5 }]]) });
|
|
54
|
+
|
|
55
|
+
assert.equal(rows.length, 1);
|
|
56
|
+
assert.equal(rows[0].awardType, 'AFC_Offensive_Player_of_Week');
|
|
57
|
+
assert.equal(rows[0].externalPlayerId, 500);
|
|
58
|
+
assert.equal(rows[0].externalTeamId, 50);
|
|
59
|
+
assert.equal(rows[0].periodIndex, 1);
|
|
60
|
+
assert.equal(rows[0].coachName, null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('extractLeagueAwards: captures every populated PeriodIndex in AnnualAwards, not just 0', async () => {
|
|
64
|
+
const player = fakeRecord({ PresentationId: 600 });
|
|
65
|
+
const playerTable = fakeTable('Player', [player]);
|
|
66
|
+
|
|
67
|
+
// PeriodIndex=0, real nonzero score -- MUST be extracted.
|
|
68
|
+
const season0Award = fakeRecord(
|
|
69
|
+
{ AwardType: 'MVP', Period: 'Season', PeriodIndex: 0, AwardScore: 463 },
|
|
70
|
+
{ Player: { tableId: playerTable._id, rowNumber: 0 } },
|
|
71
|
+
);
|
|
72
|
+
// PeriodIndex=1, real nonzero score -- MUST ALSO be extracted (this is the case the
|
|
73
|
+
// old PeriodIndex===0 restriction would have silently dropped).
|
|
74
|
+
const season1Award = fakeRecord(
|
|
75
|
+
{ AwardType: 'MVP', Period: 'Season', PeriodIndex: 1, AwardScore: 513 },
|
|
76
|
+
{ Player: { tableId: playerTable._id, rowNumber: 0 } },
|
|
77
|
+
);
|
|
78
|
+
// Historical placeholder entry: real (non-zero-index) PeriodIndex, AwardScore 0 --
|
|
79
|
+
// must NOT be extracted regardless of its PeriodIndex value.
|
|
80
|
+
const historicalPlaceholder = fakeRecord(
|
|
81
|
+
{ AwardType: 'MVP', Period: 'Season', PeriodIndex: 2024, AwardScore: 0 },
|
|
82
|
+
{ Player: { tableId: playerTable._id, rowNumber: 0 } },
|
|
83
|
+
);
|
|
84
|
+
const awardTable = fakeTable('Award', [season0Award, season1Award, historicalPlaceholder]);
|
|
85
|
+
|
|
86
|
+
const annualWrapper = fakeRecord(
|
|
87
|
+
{},
|
|
88
|
+
{
|
|
89
|
+
Award0: { tableId: awardTable._id, rowNumber: 0 },
|
|
90
|
+
Award1: { tableId: awardTable._id, rowNumber: 1 },
|
|
91
|
+
Award2: { tableId: awardTable._id, rowNumber: 2 },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
const annualWrapperTable = fakeTable('AnnualAwardsWrapper', [annualWrapper]);
|
|
95
|
+
const weeklyWrapper = fakeRecord({}, {});
|
|
96
|
+
const weeklyWrapperTable = fakeTable('WeeklyAwardsWrapper', [weeklyWrapper]);
|
|
97
|
+
|
|
98
|
+
const awardsRecord = fakeRecord(
|
|
99
|
+
{},
|
|
100
|
+
{ WeeklyAwards: { tableId: weeklyWrapperTable._id, rowNumber: 0 }, AnnualAwards: { tableId: annualWrapperTable._id, rowNumber: 0 } },
|
|
101
|
+
);
|
|
102
|
+
const awardsTable = fakeTable('Awards', [awardsRecord]);
|
|
103
|
+
const leagueRecord = fakeRecord({}, { Awards: { tableId: awardsTable._id, rowNumber: 0 } });
|
|
104
|
+
const leagueTable = fakeTable('League', [leagueRecord]);
|
|
105
|
+
|
|
106
|
+
const franchise = fakeFranchise([playerTable, awardTable, annualWrapperTable, weeklyWrapperTable, awardsTable, leagueTable, emptyLeagueHistoryAwardTable()]);
|
|
107
|
+
|
|
108
|
+
const rows = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: new Map() });
|
|
109
|
+
|
|
110
|
+
assert.equal(rows.length, 2);
|
|
111
|
+
assert.deepEqual(rows.map((r) => r.periodIndex).sort(), [0, 1]);
|
|
112
|
+
assert.deepEqual(rows.map((r) => r.awardScore).sort(), [463, 513]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('extractLeagueAwards: multiple current-season winners for the same AwardType are all extracted', async () => {
|
|
116
|
+
const player1 = fakeRecord({ PresentationId: 700 });
|
|
117
|
+
const player2 = fakeRecord({ PresentationId: 701 });
|
|
118
|
+
const playerTable = fakeTable('Player', [player1, player2]);
|
|
119
|
+
|
|
120
|
+
const award1 = fakeRecord(
|
|
121
|
+
{ AwardType: 'Offensive_Player_of_Year', Period: 'Season', PeriodIndex: 0, AwardScore: 316, Conference: 'AFC' },
|
|
122
|
+
{ Player: { tableId: playerTable._id, rowNumber: 0 } },
|
|
123
|
+
);
|
|
124
|
+
const award2 = fakeRecord(
|
|
125
|
+
{ AwardType: 'Offensive_Player_of_Year', Period: 'Season', PeriodIndex: 0, AwardScore: 440, Conference: 'NFC' },
|
|
126
|
+
{ Player: { tableId: playerTable._id, rowNumber: 1 } },
|
|
127
|
+
);
|
|
128
|
+
const awardTable = fakeTable('Award', [award1, award2]);
|
|
129
|
+
|
|
130
|
+
const annualWrapper = fakeRecord(
|
|
131
|
+
{},
|
|
132
|
+
{ Award0: { tableId: awardTable._id, rowNumber: 0 }, Award1: { tableId: awardTable._id, rowNumber: 1 } },
|
|
133
|
+
);
|
|
134
|
+
const annualWrapperTable = fakeTable('AnnualAwardsWrapper', [annualWrapper]);
|
|
135
|
+
const weeklyWrapper = fakeRecord({}, {});
|
|
136
|
+
const weeklyWrapperTable = fakeTable('WeeklyAwardsWrapper', [weeklyWrapper]);
|
|
137
|
+
const awardsRecord = fakeRecord(
|
|
138
|
+
{},
|
|
139
|
+
{ WeeklyAwards: { tableId: weeklyWrapperTable._id, rowNumber: 0 }, AnnualAwards: { tableId: annualWrapperTable._id, rowNumber: 0 } },
|
|
140
|
+
);
|
|
141
|
+
const awardsTable = fakeTable('Awards', [awardsRecord]);
|
|
142
|
+
const leagueRecord = fakeRecord({}, { Awards: { tableId: awardsTable._id, rowNumber: 0 } });
|
|
143
|
+
const leagueTable = fakeTable('League', [leagueRecord]);
|
|
144
|
+
|
|
145
|
+
const franchise = fakeFranchise([playerTable, awardTable, annualWrapperTable, weeklyWrapperTable, awardsTable, leagueTable, emptyLeagueHistoryAwardTable()]);
|
|
146
|
+
|
|
147
|
+
const rows = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: new Map() });
|
|
148
|
+
|
|
149
|
+
assert.equal(rows.length, 2);
|
|
150
|
+
assert.deepEqual(rows.map((r) => r.externalPlayerId).sort(), [700, 701]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('extractLeagueAwards: an award with an unresolvable Player ref is skipped, not thrown', async () => {
|
|
154
|
+
// Player ref with tableId 0 -- the documented "nothing here" sentinel (see
|
|
155
|
+
// franchise-table.js's resolveRef comment).
|
|
156
|
+
const award = fakeRecord(
|
|
157
|
+
{ AwardType: 'MVP', Period: 'Season', PeriodIndex: 0, AwardScore: 100 },
|
|
158
|
+
{ Player: { tableId: 0, rowNumber: 0 } },
|
|
159
|
+
);
|
|
160
|
+
const awardTable = fakeTable('Award', [award]);
|
|
161
|
+
const annualWrapper = fakeRecord({}, { Award0: { tableId: awardTable._id, rowNumber: 0 } });
|
|
162
|
+
const annualWrapperTable = fakeTable('AnnualAwardsWrapper', [annualWrapper]);
|
|
163
|
+
const weeklyWrapper = fakeRecord({}, {});
|
|
164
|
+
const weeklyWrapperTable = fakeTable('WeeklyAwardsWrapper', [weeklyWrapper]);
|
|
165
|
+
const awardsRecord = fakeRecord(
|
|
166
|
+
{},
|
|
167
|
+
{ WeeklyAwards: { tableId: weeklyWrapperTable._id, rowNumber: 0 }, AnnualAwards: { tableId: annualWrapperTable._id, rowNumber: 0 } },
|
|
168
|
+
);
|
|
169
|
+
const awardsTable = fakeTable('Awards', [awardsRecord]);
|
|
170
|
+
const leagueRecord = fakeRecord({}, { Awards: { tableId: awardsTable._id, rowNumber: 0 } });
|
|
171
|
+
const leagueTable = fakeTable('League', [leagueRecord]);
|
|
172
|
+
|
|
173
|
+
const franchise = fakeFranchise([awardTable, annualWrapperTable, weeklyWrapperTable, awardsTable, leagueTable, emptyLeagueHistoryAwardTable()]);
|
|
174
|
+
|
|
175
|
+
const rows = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: new Map() });
|
|
176
|
+
assert.equal(rows.length, 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// LeagueHistoryAward is a flat, non-wrapper table with no League.Awards involvement
|
|
180
|
+
// at all -- these tests still need an empty AnnualAwards/WeeklyAwards wrapper graph
|
|
181
|
+
// wired up (extractLeagueAwards resolves both unconditionally), but supply real
|
|
182
|
+
// LeagueHistoryAward rows to exercise extractCoachOfYearAwards.
|
|
183
|
+
|
|
184
|
+
/** Wires an empty League/Awards/WeeklyAwards/AnnualAwards graph, for tests focused on LeagueHistoryAward. */
|
|
185
|
+
function emptyAwardsGraph() {
|
|
186
|
+
const weeklyWrapper = fakeRecord({}, {});
|
|
187
|
+
const weeklyWrapperTable = fakeTable('WeeklyAwardsWrapper', [weeklyWrapper]);
|
|
188
|
+
const annualWrapper = fakeRecord({}, {});
|
|
189
|
+
const annualWrapperTable = fakeTable('AnnualAwardsWrapper', [annualWrapper]);
|
|
190
|
+
const awardsRecord = fakeRecord(
|
|
191
|
+
{},
|
|
192
|
+
{ WeeklyAwards: { tableId: weeklyWrapperTable._id, rowNumber: 0 }, AnnualAwards: { tableId: annualWrapperTable._id, rowNumber: 0 } },
|
|
193
|
+
);
|
|
194
|
+
const awardsTable = fakeTable('Awards', [awardsRecord]);
|
|
195
|
+
const leagueRecord = fakeRecord({}, { Awards: { tableId: awardsTable._id, rowNumber: 0 } });
|
|
196
|
+
const leagueTable = fakeTable('League', [leagueRecord]);
|
|
197
|
+
return [weeklyWrapperTable, annualWrapperTable, awardsTable, leagueTable];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A LeagueHistoryAward row -- flat fields only, no reference fields (see the investigation doc). */
|
|
201
|
+
function leagueHistoryAwardRow({ awardType, firstName, lastName, position = 'QB' }) {
|
|
202
|
+
return fakeRecord({ AwardType: awardType, firstName, lastName, Position: position, teamIdentity: '0' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One real 7-row LeagueHistoryAward season block, in the confirmed real-save AwardType order. */
|
|
206
|
+
function leagueHistoryAwardBlock({ invalidName, mvpName, opoyName, dpoyName, oroyName, droyName, coachName }) {
|
|
207
|
+
return [
|
|
208
|
+
leagueHistoryAwardRow({ awardType: 'INVALID', firstName: invalidName[0], lastName: invalidName[1] }),
|
|
209
|
+
leagueHistoryAwardRow({ awardType: 'MVP', firstName: mvpName[0], lastName: mvpName[1] }),
|
|
210
|
+
leagueHistoryAwardRow({ awardType: 'Offensive_Player_of_Year', firstName: opoyName[0], lastName: opoyName[1] }),
|
|
211
|
+
leagueHistoryAwardRow({ awardType: 'Defensive_Player_of_Year', firstName: dpoyName[0], lastName: dpoyName[1] }),
|
|
212
|
+
leagueHistoryAwardRow({ awardType: 'Offensive_Rookie_of_Year', firstName: oroyName[0], lastName: oroyName[1] }),
|
|
213
|
+
leagueHistoryAwardRow({ awardType: 'Defensive_Rookie_of_Year', firstName: droyName[0], lastName: droyName[1] }),
|
|
214
|
+
leagueHistoryAwardRow({ awardType: 'Coach_of_Year', firstName: coachName[0], lastName: coachName[1], position: 'Invalid_' }),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test('extractLeagueAwards: extracts Coach_of_Year from LeagueHistoryAward with the correct season block', async () => {
|
|
219
|
+
const block0 = leagueHistoryAwardBlock({
|
|
220
|
+
invalidName: ['Josh', 'Allen'],
|
|
221
|
+
mvpName: ['Jalen', 'Hurts'],
|
|
222
|
+
opoyName: ['A', 'One'],
|
|
223
|
+
dpoyName: ['B', 'Two'],
|
|
224
|
+
oroyName: ['C', 'Three'],
|
|
225
|
+
droyName: ['D', 'Four'],
|
|
226
|
+
coachName: ['Brian', 'Daboll'],
|
|
227
|
+
});
|
|
228
|
+
const block1 = leagueHistoryAwardBlock({
|
|
229
|
+
invalidName: ['Keisean', 'Nixon'],
|
|
230
|
+
mvpName: ['Joe', 'Burrow'],
|
|
231
|
+
opoyName: ['E', 'Five'],
|
|
232
|
+
dpoyName: ['F', 'Six'],
|
|
233
|
+
oroyName: ['G', 'Seven'],
|
|
234
|
+
droyName: ['H', 'Eight'],
|
|
235
|
+
coachName: ['Liam', 'Coen'],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const leagueHistoryAwardTable = fakeTable('LeagueHistoryAward', [...block0, ...block1]);
|
|
239
|
+
const franchise = fakeFranchise([leagueHistoryAwardTable, ...emptyAwardsGraph()]);
|
|
240
|
+
|
|
241
|
+
const rows = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: new Map() });
|
|
242
|
+
|
|
243
|
+
const coachRows = rows.filter((r) => r.awardType === 'Coach_of_Year');
|
|
244
|
+
assert.equal(coachRows.length, 2);
|
|
245
|
+
assert.deepEqual(coachRows.map((r) => r.periodIndex).sort(), [0, 1]);
|
|
246
|
+
|
|
247
|
+
const block0Coach = coachRows.find((r) => r.periodIndex === 0);
|
|
248
|
+
const block1Coach = coachRows.find((r) => r.periodIndex === 1);
|
|
249
|
+
assert.equal(block0Coach.coachName, 'Brian Daboll');
|
|
250
|
+
assert.equal(block1Coach.coachName, 'Liam Coen');
|
|
251
|
+
|
|
252
|
+
// No player/team resolution attempted for coach rows -- there's no reference field to resolve.
|
|
253
|
+
for (const row of coachRows) {
|
|
254
|
+
assert.equal(row.externalPlayerId, null);
|
|
255
|
+
assert.equal(row.externalTeamId, null);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The INVALID row in each block must never surface as an extracted award.
|
|
259
|
+
assert.ok(!rows.some((r) => r.awardType === 'INVALID'));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('extractLeagueAwards: a LeagueHistoryAward count that is not a multiple of 7 skips Coach_of_Year, not the whole payload', async () => {
|
|
263
|
+
// 5 rows is not a multiple of LEAGUE_HISTORY_BLOCK_SIZE (7) -- the block-size
|
|
264
|
+
// assumption may not hold for this save. This must degrade to zero Coach_of_Year
|
|
265
|
+
// rows, not throw and abort the entire extraction.
|
|
266
|
+
const leagueHistoryAwardTable = fakeTable('LeagueHistoryAward', [
|
|
267
|
+
leagueHistoryAwardRow({ awardType: 'INVALID', firstName: 'A', lastName: 'One' }),
|
|
268
|
+
leagueHistoryAwardRow({ awardType: 'MVP', firstName: 'B', lastName: 'Two' }),
|
|
269
|
+
leagueHistoryAwardRow({ awardType: 'Offensive_Player_of_Year', firstName: 'C', lastName: 'Three' }),
|
|
270
|
+
leagueHistoryAwardRow({ awardType: 'Defensive_Player_of_Year', firstName: 'D', lastName: 'Four' }),
|
|
271
|
+
leagueHistoryAwardRow({ awardType: 'Offensive_Rookie_of_Year', firstName: 'E', lastName: 'Five' }),
|
|
272
|
+
]);
|
|
273
|
+
const franchise = fakeFranchise([leagueHistoryAwardTable, ...emptyAwardsGraph()]);
|
|
274
|
+
|
|
275
|
+
const rows = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: new Map() });
|
|
276
|
+
|
|
277
|
+
assert.deepEqual(rows, []);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('extractLeagueAwards: Coach_of_Year rows have coachName set; all other award types have coachName null', async () => {
|
|
281
|
+
// MVP comes from League.Awards.AnnualAwards (a real, resolved player), while
|
|
282
|
+
// Coach_of_Year comes from LeagueHistoryAward -- wire up both sources in one test
|
|
283
|
+
// so the coachName discriminator can be checked across row shapes from BOTH tables.
|
|
284
|
+
const player = fakeRecord({ PresentationId: 800 });
|
|
285
|
+
const playerTable = fakeTable('Player', [player]);
|
|
286
|
+
const mvpAward = fakeRecord(
|
|
287
|
+
{ AwardType: 'MVP', Period: 'Season', PeriodIndex: 0, AwardScore: 463 },
|
|
288
|
+
{ Player: { tableId: playerTable._id, rowNumber: 0 } },
|
|
289
|
+
);
|
|
290
|
+
const awardTable = fakeTable('Award', [mvpAward]);
|
|
291
|
+
const annualWrapper = fakeRecord({}, { Award0: { tableId: awardTable._id, rowNumber: 0 } });
|
|
292
|
+
const annualWrapperTable = fakeTable('AnnualAwardsWrapper', [annualWrapper]);
|
|
293
|
+
const weeklyWrapper = fakeRecord({}, {});
|
|
294
|
+
const weeklyWrapperTable = fakeTable('WeeklyAwardsWrapper', [weeklyWrapper]);
|
|
295
|
+
const awardsRecord = fakeRecord(
|
|
296
|
+
{},
|
|
297
|
+
{ WeeklyAwards: { tableId: weeklyWrapperTable._id, rowNumber: 0 }, AnnualAwards: { tableId: annualWrapperTable._id, rowNumber: 0 } },
|
|
298
|
+
);
|
|
299
|
+
const awardsTable = fakeTable('Awards', [awardsRecord]);
|
|
300
|
+
const leagueRecord = fakeRecord({}, { Awards: { tableId: awardsTable._id, rowNumber: 0 } });
|
|
301
|
+
const leagueTable = fakeTable('League', [leagueRecord]);
|
|
302
|
+
|
|
303
|
+
const block0 = leagueHistoryAwardBlock({
|
|
304
|
+
invalidName: ['Trevor', 'Lawrence'],
|
|
305
|
+
mvpName: ['Joe', 'Burrow'], // LeagueHistoryAward's own MVP row is NOT extracted -- only Coach_of_Year is read from this table
|
|
306
|
+
opoyName: ['A', 'One'],
|
|
307
|
+
dpoyName: ['B', 'Two'],
|
|
308
|
+
oroyName: ['C', 'Three'],
|
|
309
|
+
droyName: ['D', 'Four'],
|
|
310
|
+
coachName: ['Tim', 'Stephens'],
|
|
311
|
+
});
|
|
312
|
+
const leagueHistoryAwardTable = fakeTable('LeagueHistoryAward', block0);
|
|
313
|
+
|
|
314
|
+
const franchise = fakeFranchise([
|
|
315
|
+
playerTable, awardTable, annualWrapperTable, weeklyWrapperTable, awardsTable, leagueTable, leagueHistoryAwardTable,
|
|
316
|
+
]);
|
|
317
|
+
|
|
318
|
+
const rows = await extractLeagueAwards(franchise, { indexTeamsByTeamIndex: new Map() });
|
|
319
|
+
|
|
320
|
+
// One MVP row (from AnnualAwards) and one Coach_of_Year row (from LeagueHistoryAward).
|
|
321
|
+
assert.equal(rows.length, 2);
|
|
322
|
+
|
|
323
|
+
const coachRows = rows.filter((r) => r.awardType === 'Coach_of_Year');
|
|
324
|
+
const otherRows = rows.filter((r) => r.awardType !== 'Coach_of_Year');
|
|
325
|
+
assert.equal(coachRows.length, 1);
|
|
326
|
+
assert.equal(coachRows[0].coachName, 'Tim Stephens');
|
|
327
|
+
assert.ok(otherRows.length > 0);
|
|
328
|
+
for (const row of otherRows) {
|
|
329
|
+
assert.equal(row.coachName, null);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractPlayers, indexPlayersByTeamAndJersey } from '../src/extract/players.js';
|
|
4
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
5
|
+
|
|
6
|
+
test('extractPlayers includes jerseyNum on each returned player', async () => {
|
|
7
|
+
const player = fakeRecord({
|
|
8
|
+
PresentationId: 100,
|
|
9
|
+
FirstName: 'Test',
|
|
10
|
+
LastName: 'Player',
|
|
11
|
+
TeamIndex: 0,
|
|
12
|
+
JerseyNum: 12,
|
|
13
|
+
YearDrafted: 0,
|
|
14
|
+
ContractStatus: 'Signed',
|
|
15
|
+
});
|
|
16
|
+
const playerTable = fakeTable('Player', [player]);
|
|
17
|
+
const franchise = fakeFranchise([playerTable]);
|
|
18
|
+
|
|
19
|
+
const players = await extractPlayers(franchise, { currentSeasonYear: 2026 });
|
|
20
|
+
assert.equal(players.length, 1);
|
|
21
|
+
assert.equal(players[0].jerseyNum, 12);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('indexPlayersByTeamAndJersey groups players by (teamIndex, jerseyNum) key', () => {
|
|
25
|
+
const players = [
|
|
26
|
+
{ teamIndex: 0, jerseyNum: 12, externalPlayerId: 100 },
|
|
27
|
+
{ teamIndex: 0, jerseyNum: 88, externalPlayerId: 101 },
|
|
28
|
+
{ teamIndex: 1, jerseyNum: 12, externalPlayerId: 102 },
|
|
29
|
+
];
|
|
30
|
+
const idx = indexPlayersByTeamAndJersey(players);
|
|
31
|
+
|
|
32
|
+
assert.deepEqual(idx.get('0|12'), [{ teamIndex: 0, jerseyNum: 12, externalPlayerId: 100 }]);
|
|
33
|
+
assert.deepEqual(idx.get('1|12'), [{ teamIndex: 1, jerseyNum: 12, externalPlayerId: 102 }]);
|
|
34
|
+
assert.equal(idx.get('99|99'), undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('indexPlayersByTeamAndJersey groups multiple players under the same key (collision case)', () => {
|
|
38
|
+
const players = [
|
|
39
|
+
{ teamIndex: 32, jerseyNum: 10, externalPlayerId: 200 },
|
|
40
|
+
{ teamIndex: 32, jerseyNum: 10, externalPlayerId: 201 },
|
|
41
|
+
];
|
|
42
|
+
const idx = indexPlayersByTeamAndJersey(players);
|
|
43
|
+
assert.equal(idx.get('32|10').length, 2);
|
|
44
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractTeams, extractDivisionConferenceByTeam } from '../src/extract/teams.js';
|
|
4
|
+
import { fakeTable, fakeRecord, fakeFranchise } from './helpers/fake-franchise.js';
|
|
5
|
+
|
|
6
|
+
// Builds a minimal two-conference, one-division-each save fragment: AFC -> AFC East -> 2 teams,
|
|
7
|
+
// NFC -> NFC West -> 1 team. Enough to prove the two-instance Conference walk without needing
|
|
8
|
+
// all 8 real divisions.
|
|
9
|
+
function buildDivisionConferenceFixture() {
|
|
10
|
+
const dolphins = fakeRecord({ PresentationId: 12, DisplayName: 'Dolphins' });
|
|
11
|
+
const bills = fakeRecord({ PresentationId: 3, DisplayName: 'Bills' });
|
|
12
|
+
const teamTable = fakeTable('Team', [dolphins, bills]);
|
|
13
|
+
|
|
14
|
+
const afcEastTeamsWrapper = fakeRecord({}, {
|
|
15
|
+
Team0: { tableId: teamTable._id, rowNumber: 0 },
|
|
16
|
+
Team1: { tableId: teamTable._id, rowNumber: 1 },
|
|
17
|
+
});
|
|
18
|
+
const afcEastTeamsWrapperTable = fakeTable('DivisionTeamsWrapper', [afcEastTeamsWrapper]);
|
|
19
|
+
const afcEast = fakeRecord({ Name: 'AFC East' }, { Teams: { tableId: afcEastTeamsWrapperTable._id, rowNumber: 0 } });
|
|
20
|
+
const afcDivisionTable = fakeTable('Division', [afcEast]);
|
|
21
|
+
|
|
22
|
+
const afcDivisionsWrapper = fakeRecord({}, { Division0: { tableId: afcDivisionTable._id, rowNumber: 0 } });
|
|
23
|
+
const afcDivisionsWrapperTable = fakeTable('ConferenceDivisionsWrapper', [afcDivisionsWrapper]);
|
|
24
|
+
const afc = fakeRecord({ Name: 'AFC' }, { Divisions: { tableId: afcDivisionsWrapperTable._id, rowNumber: 0 } });
|
|
25
|
+
const afcConferenceTable = fakeTable('Conference', [afc]);
|
|
26
|
+
|
|
27
|
+
const rams = fakeRecord({ PresentationId: 20, DisplayName: 'Rams' });
|
|
28
|
+
const nfcTeamTable = fakeTable('Team', [rams]);
|
|
29
|
+
const nfcWestTeamsWrapper = fakeRecord({}, { Team0: { tableId: nfcTeamTable._id, rowNumber: 0 } });
|
|
30
|
+
const nfcWestTeamsWrapperTable = fakeTable('DivisionTeamsWrapper', [nfcWestTeamsWrapper]);
|
|
31
|
+
const nfcWest = fakeRecord({ Name: 'NFC West' }, { Teams: { tableId: nfcWestTeamsWrapperTable._id, rowNumber: 0 } });
|
|
32
|
+
const nfcDivisionTable = fakeTable('Division', [nfcWest]);
|
|
33
|
+
|
|
34
|
+
const nfcDivisionsWrapper = fakeRecord({}, { Division0: { tableId: nfcDivisionTable._id, rowNumber: 0 } });
|
|
35
|
+
const nfcDivisionsWrapperTable = fakeTable('ConferenceDivisionsWrapper', [nfcDivisionsWrapper]);
|
|
36
|
+
const nfc = fakeRecord({ Name: 'NFC' }, { Divisions: { tableId: nfcDivisionsWrapperTable._id, rowNumber: 0 } });
|
|
37
|
+
const nfcConferenceTable = fakeTable('Conference', [nfc]); // SEPARATE Conference table instance
|
|
38
|
+
|
|
39
|
+
return fakeFranchise([
|
|
40
|
+
teamTable, afcEastTeamsWrapperTable, afcDivisionTable, afcDivisionsWrapperTable, afcConferenceTable,
|
|
41
|
+
nfcTeamTable, nfcWestTeamsWrapperTable, nfcDivisionTable, nfcDivisionsWrapperTable, nfcConferenceTable,
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('extractDivisionConferenceByTeam: walks BOTH Conference table instances and merges results', async () => {
|
|
46
|
+
const franchise = buildDivisionConferenceFixture();
|
|
47
|
+
const result = await extractDivisionConferenceByTeam(franchise);
|
|
48
|
+
|
|
49
|
+
assert.equal(result.size, 3);
|
|
50
|
+
assert.deepEqual(result.get(12), { divisionName: 'AFC East', conferenceName: 'AFC' });
|
|
51
|
+
assert.deepEqual(result.get(3), { divisionName: 'AFC East', conferenceName: 'AFC' });
|
|
52
|
+
assert.deepEqual(result.get(20), { divisionName: 'NFC West', conferenceName: 'NFC' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('extractDivisionConferenceByTeam: a team not present in any division is simply absent from the map', async () => {
|
|
56
|
+
const franchise = buildDivisionConferenceFixture();
|
|
57
|
+
const result = await extractDivisionConferenceByTeam(franchise);
|
|
58
|
+
assert.equal(result.has(999), false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('extractTeams output can be merged with extractDivisionConferenceByTeam by externalTeamId', async () => {
|
|
62
|
+
// This proves the shape buildPayload's merge step (Step 12) relies on: extractTeams
|
|
63
|
+
// rows keyed by externalTeamId match extractDivisionConferenceByTeam's map keys.
|
|
64
|
+
const team = fakeRecord({
|
|
65
|
+
PresentationId: 12, TeamIndex: 0, TEAM_TYPE: 'Current',
|
|
66
|
+
DisplayName: 'Dolphins', NickName: 'Fins', ShortName: 'MIA',
|
|
67
|
+
HomeWin: 3, RoadWin: 2, HomeLoss: 1, RoadLoss: 1, HomeTie: 0, RoadTie: 0,
|
|
68
|
+
CurSeasonDivStanding: 1, CurSeasonConfStanding: 3, CurSeasonLeagStanding: 5,
|
|
69
|
+
});
|
|
70
|
+
const teamTable = fakeTable('Team', [team]);
|
|
71
|
+
const franchise = fakeFranchise([teamTable]);
|
|
72
|
+
|
|
73
|
+
const teams = await extractTeams(franchise);
|
|
74
|
+
assert.equal(teams.length, 1);
|
|
75
|
+
assert.equal(teams[0].externalTeamId, 12);
|
|
76
|
+
// divisionName/conferenceName are NOT set by extractTeams itself -- confirms the
|
|
77
|
+
// merge happens one level up, in buildPayload (Step 12), not inside this function.
|
|
78
|
+
assert.equal('divisionName' in teams[0], false);
|
|
79
|
+
});
|