cricinfo-cli-go 0.1.0 → 0.1.2
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/internal/cli/matches.go +30 -2
- package/internal/cli/matches_test.go +11 -0
- package/internal/cricinfo/analysis.go +216 -46
- package/internal/cricinfo/analysis_phase15_test.go +38 -0
- package/internal/cricinfo/historical_hydration.go +82 -42
- package/internal/cricinfo/matches.go +582 -43
- package/internal/cricinfo/normalize_entities.go +16 -0
- package/internal/cricinfo/render_contract.go +52 -12
- package/internal/cricinfo/renderer.go +196 -11
- package/internal/cricinfo/resolver.go +53 -9
- package/package.json +1 -1
package/internal/cli/matches.go
CHANGED
|
@@ -19,6 +19,7 @@ type matchCommandService interface {
|
|
|
19
19
|
Details(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
|
|
20
20
|
Plays(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
|
|
21
21
|
Situation(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
|
|
22
|
+
Phases(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
|
|
22
23
|
Innings(ctx context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error)
|
|
23
24
|
Partnerships(ctx context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error)
|
|
24
25
|
FallOfWicket(ctx context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error)
|
|
@@ -77,7 +78,10 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
|
|
|
77
78
|
Args: cobra.NoArgs,
|
|
78
79
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
79
80
|
return runMatchCommand(cmd, global, func(ctx context.Context, service matchCommandService) (cricinfo.NormalizedResult, error) {
|
|
80
|
-
return service.List(ctx, cricinfo.MatchListOptions{
|
|
81
|
+
return service.List(ctx, cricinfo.MatchListOptions{
|
|
82
|
+
Limit: opts.limit,
|
|
83
|
+
LeagueID: opts.leagueID,
|
|
84
|
+
})
|
|
81
85
|
})
|
|
82
86
|
},
|
|
83
87
|
}
|
|
@@ -98,7 +102,10 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
|
|
|
98
102
|
Args: cobra.NoArgs,
|
|
99
103
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
100
104
|
return runMatchCommand(cmd, global, func(ctx context.Context, service matchCommandService) (cricinfo.NormalizedResult, error) {
|
|
101
|
-
return service.Live(ctx, cricinfo.MatchListOptions{
|
|
105
|
+
return service.Live(ctx, cricinfo.MatchListOptions{
|
|
106
|
+
Limit: opts.limit,
|
|
107
|
+
LeagueID: opts.leagueID,
|
|
108
|
+
})
|
|
102
109
|
})
|
|
103
110
|
},
|
|
104
111
|
}
|
|
@@ -224,6 +231,26 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
|
|
|
224
231
|
},
|
|
225
232
|
}
|
|
226
233
|
|
|
234
|
+
phasesCmd := &cobra.Command{
|
|
235
|
+
Use: "phases <match>",
|
|
236
|
+
Short: "Show powerplay, middle, and death-over phase splits for each innings",
|
|
237
|
+
Long: strings.Join([]string{
|
|
238
|
+
"Resolve a match and show fan-friendly phase splits (powerplay/middle/death) with momentum markers.",
|
|
239
|
+
"",
|
|
240
|
+
"Next steps:",
|
|
241
|
+
" cricinfo matches scorecard <match>",
|
|
242
|
+
" cricinfo matches deliveries <match> --team <team> --innings <n> --period <n>",
|
|
243
|
+
" cricinfo matches fow <match> --team <team> --innings <n> --period <n>",
|
|
244
|
+
}, "\n"),
|
|
245
|
+
Args: cobra.MinimumNArgs(1),
|
|
246
|
+
RunE: func(cmd *cobra.Command, args []string) error {
|
|
247
|
+
query := strings.TrimSpace(strings.Join(args, " "))
|
|
248
|
+
return runMatchCommand(cmd, global, func(ctx context.Context, service matchCommandService) (cricinfo.NormalizedResult, error) {
|
|
249
|
+
return service.Phases(ctx, query, cricinfo.MatchLookupOptions{LeagueID: opts.leagueID})
|
|
250
|
+
})
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
|
|
227
254
|
inningsCmd := &cobra.Command{
|
|
228
255
|
Use: "innings <match>",
|
|
229
256
|
Short: "Show innings summaries with over and wicket timelines",
|
|
@@ -369,6 +396,7 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
|
|
|
369
396
|
detailsCmd,
|
|
370
397
|
playsCmd,
|
|
371
398
|
situationCmd,
|
|
399
|
+
phasesCmd,
|
|
372
400
|
inningsCmd,
|
|
373
401
|
partnershipsCmd,
|
|
374
402
|
fowCmd,
|
|
@@ -19,6 +19,7 @@ type fakeMatchService struct {
|
|
|
19
19
|
detailsResult cricinfo.NormalizedResult
|
|
20
20
|
playsResult cricinfo.NormalizedResult
|
|
21
21
|
situationResult cricinfo.NormalizedResult
|
|
22
|
+
phasesResult cricinfo.NormalizedResult
|
|
22
23
|
inningsResult cricinfo.NormalizedResult
|
|
23
24
|
partnerships cricinfo.NormalizedResult
|
|
24
25
|
fowResult cricinfo.NormalizedResult
|
|
@@ -68,6 +69,10 @@ func (f *fakeMatchService) Situation(context.Context, string, cricinfo.MatchLook
|
|
|
68
69
|
return f.situationResult, nil
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
func (f *fakeMatchService) Phases(context.Context, string, cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error) {
|
|
73
|
+
return f.phasesResult, nil
|
|
74
|
+
}
|
|
75
|
+
|
|
71
76
|
func (f *fakeMatchService) Innings(_ context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error) {
|
|
72
77
|
f.inningsQueries = append(f.inningsQueries, query)
|
|
73
78
|
f.inningsOpts = append(f.inningsOpts, opts)
|
|
@@ -150,6 +155,12 @@ func TestMatchesCommandsRenderTextAndJSON(t *testing.T) {
|
|
|
150
155
|
detailsResult: cricinfo.NewListResult(cricinfo.EntityDeliveryEvent, []any{delivery}),
|
|
151
156
|
playsResult: cricinfo.NewListResult(cricinfo.EntityDeliveryEvent, []any{delivery}),
|
|
152
157
|
situationResult: cricinfo.NewDataResult(cricinfo.EntityMatchSituation, situation),
|
|
158
|
+
phasesResult: cricinfo.NewDataResult(cricinfo.EntityMatchPhases, cricinfo.MatchPhases{
|
|
159
|
+
MatchID: "1529474",
|
|
160
|
+
Innings: []cricinfo.MatchPhaseInning{
|
|
161
|
+
{TeamName: "BOOST", InningsNumber: 1, Period: 3, Score: "69/2 (19 ov)"},
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
153
164
|
inningsResult: cricinfo.NewListResult(cricinfo.EntityInnings, []any{
|
|
154
165
|
cricinfo.Innings{
|
|
155
166
|
TeamName: "BOOST",
|
|
@@ -467,54 +467,62 @@ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOption
|
|
|
467
467
|
|
|
468
468
|
agg := map[string]*analysisAggregate{}
|
|
469
469
|
warnings := append([]string{}, run.warnings...)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
|
|
475
|
-
return *statusErr, nil
|
|
476
|
-
}
|
|
477
|
-
warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
|
|
478
|
-
continue
|
|
470
|
+
if run.mode == analysisScopeMatch {
|
|
471
|
+
if fastAgg, fastWarnings, used := s.hydrateMatchScopeBattingFromScorecard(ctx, run, filters, groupBy); used {
|
|
472
|
+
agg = fastAgg
|
|
473
|
+
warnings = append(warnings, fastWarnings...)
|
|
479
474
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
for _,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
TeamID: strings.TrimSpace(player.TeamID),
|
|
491
|
-
TeamName: strings.TrimSpace(teamName),
|
|
492
|
-
PlayerID: strings.TrimSpace(player.PlayerID),
|
|
493
|
-
PlayerName: strings.TrimSpace(playerName),
|
|
494
|
-
CountValue: 1,
|
|
495
|
-
Fours: totals.fours,
|
|
496
|
-
BattingSixes: totals.sixes,
|
|
497
|
-
RunsScored: totals.runs,
|
|
498
|
-
BallsFaced: totals.balls,
|
|
499
|
-
StrikeSample: totals.strikeRate,
|
|
500
|
-
}
|
|
501
|
-
if !filters.matches(row) {
|
|
475
|
+
}
|
|
476
|
+
if len(agg) == 0 {
|
|
477
|
+
for _, match := range run.session.ScopedMatches() {
|
|
478
|
+
seasonID := seasonForMatch(match, run.seasonHint)
|
|
479
|
+
players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
|
|
480
|
+
if hydrateErr != nil {
|
|
481
|
+
if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
|
|
482
|
+
return *statusErr, nil
|
|
483
|
+
}
|
|
484
|
+
warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
|
|
502
485
|
continue
|
|
503
486
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
487
|
+
warnings = append(warnings, playerWarnings...)
|
|
488
|
+
|
|
489
|
+
for _, player := range players {
|
|
490
|
+
totals := extractBattingTotals(player)
|
|
491
|
+
playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
|
|
492
|
+
teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
|
|
493
|
+
row := analysisSourceRow{
|
|
494
|
+
MatchID: strings.TrimSpace(player.MatchID),
|
|
495
|
+
LeagueID: strings.TrimSpace(player.LeagueID),
|
|
496
|
+
SeasonID: seasonID,
|
|
497
|
+
TeamID: strings.TrimSpace(player.TeamID),
|
|
498
|
+
TeamName: strings.TrimSpace(teamName),
|
|
499
|
+
PlayerID: strings.TrimSpace(player.PlayerID),
|
|
500
|
+
PlayerName: strings.TrimSpace(playerName),
|
|
501
|
+
CountValue: 1,
|
|
502
|
+
Fours: totals.fours,
|
|
503
|
+
BattingSixes: totals.sixes,
|
|
504
|
+
RunsScored: totals.runs,
|
|
505
|
+
BallsFaced: totals.balls,
|
|
506
|
+
StrikeSample: totals.strikeRate,
|
|
507
|
+
}
|
|
508
|
+
if !filters.matches(row) {
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
key, dims := buildAnalysisGroup(row, groupBy)
|
|
512
|
+
entry := agg[key]
|
|
513
|
+
if entry == nil {
|
|
514
|
+
entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
|
|
515
|
+
agg[key] = entry
|
|
516
|
+
}
|
|
517
|
+
entry.matchIDs[row.MatchID] = struct{}{}
|
|
518
|
+
entry.fours += row.Fours
|
|
519
|
+
entry.battingSixes += row.BattingSixes
|
|
520
|
+
entry.runsScored += row.RunsScored
|
|
521
|
+
entry.ballsFaced += row.BallsFaced
|
|
522
|
+
if row.StrikeSample > 0 {
|
|
523
|
+
entry.strikeRateTotal += row.StrikeSample
|
|
524
|
+
entry.strikeRateCount++
|
|
525
|
+
}
|
|
518
526
|
}
|
|
519
527
|
}
|
|
520
528
|
}
|
|
@@ -559,6 +567,133 @@ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOption
|
|
|
559
567
|
return analysisResult(EntityAnalysisBat, view, warnings), nil
|
|
560
568
|
}
|
|
561
569
|
|
|
570
|
+
func (s *AnalysisService) hydrateMatchScopeBattingFromScorecard(
|
|
571
|
+
ctx context.Context,
|
|
572
|
+
run *analysisScopeRun,
|
|
573
|
+
filters analysisFilterSpec,
|
|
574
|
+
groupBy []string,
|
|
575
|
+
) (map[string]*analysisAggregate, []string, bool) {
|
|
576
|
+
if run == nil || run.session == nil {
|
|
577
|
+
return nil, nil, false
|
|
578
|
+
}
|
|
579
|
+
matches := run.session.ScopedMatches()
|
|
580
|
+
if len(matches) != 1 {
|
|
581
|
+
return nil, nil, false
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
match := matches[0]
|
|
585
|
+
scorecardRef := matchSubresourceRef(match, "matchcards", "matchcards")
|
|
586
|
+
if strings.TrimSpace(scorecardRef) == "" {
|
|
587
|
+
return nil, nil, false
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
resolved, err := s.client.ResolveRefChain(ctx, scorecardRef)
|
|
591
|
+
if err != nil {
|
|
592
|
+
return nil, nil, false
|
|
593
|
+
}
|
|
594
|
+
scorecard, err := NormalizeMatchScorecard(resolved.Body, match)
|
|
595
|
+
if err != nil {
|
|
596
|
+
return nil, nil, false
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
teamIDByAlias := map[string]string{}
|
|
600
|
+
playerNameByID := map[string]string{}
|
|
601
|
+
for _, team := range match.Teams {
|
|
602
|
+
teamID := strings.TrimSpace(team.ID)
|
|
603
|
+
for _, raw := range []string{team.Name, team.ShortName, team.Abbreviation} {
|
|
604
|
+
alias := normalizeAlias(raw)
|
|
605
|
+
if alias == "" {
|
|
606
|
+
continue
|
|
607
|
+
}
|
|
608
|
+
if _, exists := teamIDByAlias[alias]; !exists {
|
|
609
|
+
teamIDByAlias[alias] = teamID
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
rosterRef := nonEmpty(strings.TrimSpace(team.RosterRef), competitorSubresourceRef(match, team.ID, "roster"))
|
|
614
|
+
if strings.TrimSpace(rosterRef) == "" {
|
|
615
|
+
continue
|
|
616
|
+
}
|
|
617
|
+
rosterDoc, rosterErr := s.client.ResolveRefChain(ctx, rosterRef)
|
|
618
|
+
if rosterErr != nil {
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
entries, normalizeErr := NormalizeTeamRosterEntries(rosterDoc.Body, team, TeamScopeMatch, match.ID)
|
|
622
|
+
if normalizeErr != nil {
|
|
623
|
+
continue
|
|
624
|
+
}
|
|
625
|
+
for _, entry := range entries {
|
|
626
|
+
playerID := strings.TrimSpace(entry.PlayerID)
|
|
627
|
+
playerName := strings.TrimSpace(entry.DisplayName)
|
|
628
|
+
if playerID == "" || playerName == "" {
|
|
629
|
+
continue
|
|
630
|
+
}
|
|
631
|
+
if _, exists := playerNameByID[playerID]; !exists {
|
|
632
|
+
playerNameByID[playerID] = playerName
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
seasonID := seasonForMatch(match, run.seasonHint)
|
|
638
|
+
agg := map[string]*analysisAggregate{}
|
|
639
|
+
for _, card := range scorecard.BattingCards {
|
|
640
|
+
teamName := strings.TrimSpace(card.TeamName)
|
|
641
|
+
teamID := teamIDByAlias[normalizeAlias(teamName)]
|
|
642
|
+
teamName = analysisDisplayTeamName(s.resolver, teamID, teamName)
|
|
643
|
+
|
|
644
|
+
for _, player := range card.Players {
|
|
645
|
+
runs := analysisNumericString(player.Runs)
|
|
646
|
+
balls := analysisNumericString(player.BallsFaced)
|
|
647
|
+
fours := analysisNumericString(player.Fours)
|
|
648
|
+
sixes := analysisNumericString(player.Sixes)
|
|
649
|
+
strikeRate := 0.0
|
|
650
|
+
if balls > 0 {
|
|
651
|
+
strikeRate = (float64(runs) * 100.0) / float64(balls)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
row := analysisSourceRow{
|
|
655
|
+
MatchID: strings.TrimSpace(match.ID),
|
|
656
|
+
LeagueID: strings.TrimSpace(nonEmpty(match.LeagueID, run.scope.League.ID)),
|
|
657
|
+
SeasonID: seasonID,
|
|
658
|
+
TeamID: teamID,
|
|
659
|
+
TeamName: teamName,
|
|
660
|
+
PlayerID: strings.TrimSpace(player.PlayerID),
|
|
661
|
+
PlayerName: analysisDisplayPlayerName(
|
|
662
|
+
s.resolver,
|
|
663
|
+
player.PlayerID,
|
|
664
|
+
nonEmpty(playerNameByID[strings.TrimSpace(player.PlayerID)], player.PlayerName),
|
|
665
|
+
),
|
|
666
|
+
CountValue: 1,
|
|
667
|
+
Fours: fours,
|
|
668
|
+
BattingSixes: sixes,
|
|
669
|
+
RunsScored: runs,
|
|
670
|
+
BallsFaced: balls,
|
|
671
|
+
StrikeSample: strikeRate,
|
|
672
|
+
}
|
|
673
|
+
if !filters.matches(row) {
|
|
674
|
+
continue
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
key, dims := buildAnalysisGroup(row, groupBy)
|
|
678
|
+
entry := agg[key]
|
|
679
|
+
if entry == nil {
|
|
680
|
+
entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
|
|
681
|
+
agg[key] = entry
|
|
682
|
+
}
|
|
683
|
+
entry.matchIDs[row.MatchID] = struct{}{}
|
|
684
|
+
entry.fours += row.Fours
|
|
685
|
+
entry.battingSixes += row.BattingSixes
|
|
686
|
+
entry.runsScored += row.RunsScored
|
|
687
|
+
entry.ballsFaced += row.BallsFaced
|
|
688
|
+
if row.StrikeSample > 0 {
|
|
689
|
+
entry.strikeRateTotal += row.StrikeSample
|
|
690
|
+
entry.strikeRateCount++
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return agg, nil, true
|
|
695
|
+
}
|
|
696
|
+
|
|
562
697
|
// Partnerships ranks partnerships over match or season scope.
|
|
563
698
|
func (s *AnalysisService) Partnerships(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
|
|
564
699
|
groupBy, err := parseGroupBy(opts.GroupBy, []string{"innings"}, analysisGroupPartnershipAllowed)
|
|
@@ -1191,7 +1326,7 @@ type battingTotals struct {
|
|
|
1191
1326
|
}
|
|
1192
1327
|
|
|
1193
1328
|
func extractBattingTotals(player PlayerMatch) battingTotals {
|
|
1194
|
-
totals := battingTotals{strikeRate: player.Summary.StrikeRate
|
|
1329
|
+
totals := battingTotals{strikeRate: player.Summary.StrikeRate}
|
|
1195
1330
|
for _, category := range player.Batting {
|
|
1196
1331
|
for _, stat := range category.Stats {
|
|
1197
1332
|
switch normalizeStatName(stat.Name) {
|
|
@@ -1210,6 +1345,12 @@ func extractBattingTotals(player PlayerMatch) battingTotals {
|
|
|
1210
1345
|
}
|
|
1211
1346
|
}
|
|
1212
1347
|
}
|
|
1348
|
+
|
|
1349
|
+
// Batting summary fields are frequently derived from the same stats payload.
|
|
1350
|
+
// Prefer the larger value rather than summing to avoid duplicate counting.
|
|
1351
|
+
if player.Summary.BallsFaced > 0 {
|
|
1352
|
+
totals.balls = analysisMaxInt(totals.balls, player.Summary.BallsFaced)
|
|
1353
|
+
}
|
|
1213
1354
|
return totals
|
|
1214
1355
|
}
|
|
1215
1356
|
|
|
@@ -1267,6 +1408,35 @@ func analysisMaxInt(a, b int) int {
|
|
|
1267
1408
|
return b
|
|
1268
1409
|
}
|
|
1269
1410
|
|
|
1411
|
+
func analysisNumericString(raw string) int {
|
|
1412
|
+
raw = strings.TrimSpace(raw)
|
|
1413
|
+
if raw == "" {
|
|
1414
|
+
return 0
|
|
1415
|
+
}
|
|
1416
|
+
start := -1
|
|
1417
|
+
end := -1
|
|
1418
|
+
for i, r := range raw {
|
|
1419
|
+
if r >= '0' && r <= '9' {
|
|
1420
|
+
if start == -1 {
|
|
1421
|
+
start = i
|
|
1422
|
+
}
|
|
1423
|
+
end = i + 1
|
|
1424
|
+
continue
|
|
1425
|
+
}
|
|
1426
|
+
if start != -1 {
|
|
1427
|
+
break
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if start == -1 || end == -1 {
|
|
1431
|
+
return 0
|
|
1432
|
+
}
|
|
1433
|
+
value, err := strconv.Atoi(raw[start:end])
|
|
1434
|
+
if err != nil {
|
|
1435
|
+
return 0
|
|
1436
|
+
}
|
|
1437
|
+
return value
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1270
1440
|
func analysisDisplayPlayerName(resolver *Resolver, playerID, fallback string) string {
|
|
1271
1441
|
name := strings.TrimSpace(fallback)
|
|
1272
1442
|
if name != "" {
|
|
@@ -203,6 +203,44 @@ func TestPhase15BowlingActivityFilterSkipsNonBowlers(t *testing.T) {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
func TestPhase15ExtractBattingTotalsDoesNotDoubleCountSummaryBalls(t *testing.T) {
|
|
207
|
+
t.Parallel()
|
|
208
|
+
|
|
209
|
+
player := PlayerMatch{
|
|
210
|
+
PlayerID: "1408688",
|
|
211
|
+
PlayerName: "Vaibhav Sooryavanshi",
|
|
212
|
+
Batting: []StatCategory{
|
|
213
|
+
{
|
|
214
|
+
Name: "general",
|
|
215
|
+
Stats: []StatValue{
|
|
216
|
+
{Name: "runs", Value: "78"},
|
|
217
|
+
{Name: "ballsFaced", Value: "26"},
|
|
218
|
+
{Name: "fours", Value: "8"},
|
|
219
|
+
{Name: "sixes", Value: "7"},
|
|
220
|
+
{Name: "strikeRate", Value: "300"},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
Summary: PlayerMatchSummary{
|
|
225
|
+
BallsFaced: 26,
|
|
226
|
+
StrikeRate: 300,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
totals := extractBattingTotals(player)
|
|
231
|
+
if totals.runs != 78 || totals.balls != 26 {
|
|
232
|
+
t.Fatalf("expected 78/26 batting totals, got runs=%d balls=%d", totals.runs, totals.balls)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
agg := &analysisAggregate{
|
|
236
|
+
runsScored: totals.runs,
|
|
237
|
+
ballsFaced: totals.balls,
|
|
238
|
+
}
|
|
239
|
+
if got := strikeRateFromAggregate(agg); got != 300 {
|
|
240
|
+
t.Fatalf("expected strike rate 300, got %.2f", got)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
206
244
|
func TestLivePhase15SmallHistoricalScope(t *testing.T) {
|
|
207
245
|
t.Parallel()
|
|
208
246
|
requireLiveMatrix(t)
|
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"sort"
|
|
7
7
|
"strconv"
|
|
8
8
|
"strings"
|
|
9
|
+
"sync"
|
|
9
10
|
"time"
|
|
10
11
|
)
|
|
11
12
|
|
|
@@ -133,6 +134,8 @@ type HistoricalScopeSession struct {
|
|
|
133
134
|
partnershipWarnByMatch map[string][]string
|
|
134
135
|
|
|
135
136
|
metrics HydrationMetrics
|
|
137
|
+
|
|
138
|
+
resolveMu sync.Mutex
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
func newHistoricalScopeSession(client *Client, resolver *Resolver, opts HistoricalScopeOptions) *HistoricalScopeSession {
|
|
@@ -292,52 +295,78 @@ func (s *HistoricalScopeSession) HydratePlayerMatchSummaries(ctx context.Context
|
|
|
292
295
|
continue
|
|
293
296
|
}
|
|
294
297
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
298
|
+
type playerHydrationResult struct {
|
|
299
|
+
item *PlayerMatch
|
|
300
|
+
warning string
|
|
301
|
+
}
|
|
302
|
+
results := make([]playerHydrationResult, len(entries))
|
|
303
|
+
sem := make(chan struct{}, detailSubresourceFetchConcurrency)
|
|
304
|
+
var wg sync.WaitGroup
|
|
305
|
+
for i, entry := range entries {
|
|
306
|
+
wg.Add(1)
|
|
307
|
+
go func(index int, entry TeamRosterEntry, team Team) {
|
|
308
|
+
defer wg.Done()
|
|
309
|
+
sem <- struct{}{}
|
|
310
|
+
defer func() { <-sem }()
|
|
304
311
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
312
|
+
playerID := strings.TrimSpace(entry.PlayerID)
|
|
313
|
+
if playerID == "" {
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if strings.TrimSpace(entry.DisplayName) == "" && s.resolver != nil {
|
|
317
|
+
_ = s.resolver.seedPlayerByID(ctx, playerID, match.LeagueID, match.ID)
|
|
318
|
+
}
|
|
319
|
+
entry = s.enrichRosterEntryFromIndex(entry)
|
|
310
320
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
321
|
+
statsRef := rosterPlayerStatisticsRef(match, team, entry)
|
|
322
|
+
if statsRef == "" {
|
|
323
|
+
results[index] = playerHydrationResult{warning: fmt.Sprintf("player %s has no match statistics route", playerID)}
|
|
324
|
+
return
|
|
325
|
+
}
|
|
316
326
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
327
|
+
statsDoc, err := s.resolve(ctx, statsRef)
|
|
328
|
+
if err != nil {
|
|
329
|
+
results[index] = playerHydrationResult{warning: fmt.Sprintf("player statistics %s: %v", statsRef, err)}
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
categories, err := NormalizeStatCategories(statsDoc.Body)
|
|
334
|
+
if err != nil {
|
|
335
|
+
results[index] = playerHydrationResult{warning: fmt.Sprintf("player statistics %s: %v", statsDoc.CanonicalRef, err)}
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
batting, bowling, fielding := splitPlayerStatCategories(categories)
|
|
340
|
+
playerMatch := PlayerMatch{
|
|
341
|
+
PlayerID: playerID,
|
|
342
|
+
PlayerRef: entry.PlayerRef,
|
|
343
|
+
PlayerName: nonEmpty(entry.DisplayName, "Unknown Player"),
|
|
344
|
+
MatchID: match.ID,
|
|
345
|
+
CompetitionID: nonEmpty(match.CompetitionID, match.ID),
|
|
346
|
+
EventID: match.EventID,
|
|
347
|
+
LeagueID: match.LeagueID,
|
|
348
|
+
TeamID: team.ID,
|
|
349
|
+
TeamName: nonEmpty(team.ShortName, team.Name, "Unknown Team"),
|
|
350
|
+
StatisticsRef: statsDoc.CanonicalRef,
|
|
351
|
+
LinescoresRef: rosterPlayerLinescoresRef(match, team, entry),
|
|
352
|
+
Batting: batting,
|
|
353
|
+
Bowling: bowling,
|
|
354
|
+
Fielding: fielding,
|
|
355
|
+
Summary: summarizePlayerMatchCategories(categories),
|
|
356
|
+
}
|
|
357
|
+
results[index] = playerHydrationResult{item: &playerMatch}
|
|
358
|
+
}(i, entry, team)
|
|
359
|
+
}
|
|
360
|
+
wg.Wait()
|
|
361
|
+
|
|
362
|
+
for _, result := range results {
|
|
363
|
+
if strings.TrimSpace(result.warning) != "" {
|
|
364
|
+
warnings = append(warnings, result.warning)
|
|
320
365
|
continue
|
|
321
366
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
PlayerID: playerID,
|
|
326
|
-
PlayerRef: entry.PlayerRef,
|
|
327
|
-
PlayerName: nonEmpty(entry.DisplayName, "Unknown Player"),
|
|
328
|
-
MatchID: match.ID,
|
|
329
|
-
CompetitionID: nonEmpty(match.CompetitionID, match.ID),
|
|
330
|
-
EventID: match.EventID,
|
|
331
|
-
LeagueID: match.LeagueID,
|
|
332
|
-
TeamID: team.ID,
|
|
333
|
-
TeamName: nonEmpty(team.ShortName, team.Name, "Unknown Team"),
|
|
334
|
-
StatisticsRef: statsDoc.CanonicalRef,
|
|
335
|
-
LinescoresRef: rosterPlayerLinescoresRef(match, team, entry),
|
|
336
|
-
Batting: batting,
|
|
337
|
-
Bowling: bowling,
|
|
338
|
-
Fielding: fielding,
|
|
339
|
-
Summary: summarizePlayerMatchCategories(categories),
|
|
340
|
-
})
|
|
367
|
+
if result.item != nil {
|
|
368
|
+
items = append(items, *result.item)
|
|
369
|
+
}
|
|
341
370
|
}
|
|
342
371
|
}
|
|
343
372
|
|
|
@@ -1397,23 +1426,34 @@ func (s *HistoricalScopeSession) resolve(ctx context.Context, ref string) (*Reso
|
|
|
1397
1426
|
return nil, fmt.Errorf("ref is empty")
|
|
1398
1427
|
}
|
|
1399
1428
|
|
|
1429
|
+
s.resolveMu.Lock()
|
|
1400
1430
|
if cached, ok := s.resolvedDocs[ref]; ok {
|
|
1401
1431
|
s.metrics.ResolveCacheHits++
|
|
1432
|
+
s.resolveMu.Unlock()
|
|
1402
1433
|
return cached, nil
|
|
1403
1434
|
}
|
|
1435
|
+
s.resolveMu.Unlock()
|
|
1404
1436
|
|
|
1405
1437
|
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
1406
1438
|
if err != nil {
|
|
1407
1439
|
return nil, err
|
|
1408
1440
|
}
|
|
1409
|
-
s.metrics.ResolveCacheMisses++
|
|
1410
1441
|
|
|
1411
1442
|
copied := *resolved
|
|
1412
1443
|
pointer := &copied
|
|
1413
1444
|
keys := compactWarnings([]string{ref, copied.RequestedRef, copied.CanonicalRef})
|
|
1445
|
+
|
|
1446
|
+
s.resolveMu.Lock()
|
|
1447
|
+
if cached, ok := s.resolvedDocs[ref]; ok {
|
|
1448
|
+
s.metrics.ResolveCacheHits++
|
|
1449
|
+
s.resolveMu.Unlock()
|
|
1450
|
+
return cached, nil
|
|
1451
|
+
}
|
|
1452
|
+
s.metrics.ResolveCacheMisses++
|
|
1414
1453
|
for _, key := range keys {
|
|
1415
1454
|
s.resolvedDocs[key] = pointer
|
|
1416
1455
|
}
|
|
1456
|
+
s.resolveMu.Unlock()
|
|
1417
1457
|
return pointer, nil
|
|
1418
1458
|
}
|
|
1419
1459
|
|