Skip to content

Commit d9fdb63

Browse files
authored
Merge pull request #156 from 0xjuanma/fix/wc-ui
fix[ui/worldcup]: resolve ambiguous flags and backfill 2026 qualifier coverage
2 parents fcec2ee + 2202247 commit d9fdb63

5 files changed

Lines changed: 266 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
### Changed
1414

1515
### Fixed
16+
- **World Cup Flags** - Fixed ambiguous FotMob short codes (e.g. South Africa/South Korea both `SOU`) and missing 2026 qualifier flags causing teams to render with the wrong code or no flag.
1617

1718
## [0.26.0] - 2026-06-10
1819

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package worldcup
2+
3+
import (
4+
"sort"
5+
"testing"
6+
)
7+
8+
// alternateFlagCodes lists flagEmojis keys that intentionally have no
9+
// matching wcNameToCode entry: they exist purely so FotMob payloads that
10+
// surface alternate codes (e.g. "IRI" for Iran, "HOL" for Netherlands)
11+
// still render with a flag. The canonical name → code mapping lives in
12+
// wcNameToCode under the FIFA-standard code.
13+
var alternateFlagCodes = map[string]bool{
14+
"HOL": true, // Netherlands alternate
15+
"IRI": true, // Iran alternate
16+
"GBR": true, // Great Britain (no corresponding national team in WC)
17+
}
18+
19+
// unflaggedNameCodes lists wcNameToCode values that intentionally have no
20+
// flagEmojis entry because the country has no representable Unicode flag
21+
// emoji. Northern Ireland is the canonical example: there is no Unicode
22+
// regional indicator or subdivision tag for it (only ENG, SCT, WLS exist).
23+
// Teams in this list still get their FIFA code rendered, just without an
24+
// emoji prefix.
25+
var unflaggedNameCodes = map[string]bool{
26+
"NIR": true, // Northern Ireland — no Unicode flag exists
27+
}
28+
29+
// TestFlagCoverage_NameOverridesHaveFlags asserts every code that
30+
// wcNameToCode maps to is also a key in flagEmojis. Without this, adding
31+
// a new country to wcNameToCode without its flag would render as
32+
// " XYZ" silently, with no compile-time or runtime warning.
33+
func TestFlagCoverage_NameOverridesHaveFlags(t *testing.T) {
34+
var missing []string
35+
for name, code := range wcNameToCode {
36+
if unflaggedNameCodes[code] {
37+
continue
38+
}
39+
if _, ok := flagEmojis[code]; !ok {
40+
missing = append(missing, name+" → "+code)
41+
}
42+
}
43+
if len(missing) > 0 {
44+
sort.Strings(missing)
45+
t.Errorf("wcNameToCode entries missing a matching flagEmojis flag (%d):\n %s\n"+
46+
"add the flag to flagEmojis, or add the code to unflaggedNameCodes if no Unicode flag exists",
47+
len(missing), joinNewline(missing))
48+
}
49+
}
50+
51+
// TestFlagCoverage_NoOrphanedFlagEntries asserts every flagEmojis key
52+
// (except the documented alternate codes) is reachable from at least one
53+
// wcNameToCode value. This prevents flag entries from drifting out of
54+
// sync — e.g. a flag added without the corresponding name override would
55+
// never render in any World Cup view.
56+
func TestFlagCoverage_NoOrphanedFlagEntries(t *testing.T) {
57+
reachable := make(map[string]bool, len(wcNameToCode))
58+
for _, code := range wcNameToCode {
59+
reachable[code] = true
60+
}
61+
62+
var orphans []string
63+
for code := range flagEmojis {
64+
if alternateFlagCodes[code] {
65+
continue
66+
}
67+
if !reachable[code] {
68+
orphans = append(orphans, code)
69+
}
70+
}
71+
if len(orphans) > 0 {
72+
sort.Strings(orphans)
73+
t.Errorf("flagEmojis entries with no wcNameToCode mapping (%d):\n %s\n"+
74+
"add the country name to wcNameToCode, or add the code to alternateFlagCodes if intentional",
75+
len(orphans), joinNewline(orphans))
76+
}
77+
}
78+
79+
func joinNewline(items []string) string {
80+
out := ""
81+
for i, s := range items {
82+
if i > 0 {
83+
out += "\n "
84+
}
85+
out += s
86+
}
87+
return out
88+
}

internal/ui/worldcup/flags_emoji.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ func FlagEmoji(shortName string) string {
1313
}
1414

1515
// flagEmojis maps FIFA 3-letter codes to Unicode regional indicator flag emojis.
16-
// Covers all 32 WC 2022 teams plus the additional 16 teams confirmed for 2026.
16+
// Covers all 32 WC 2022 teams plus the additional teams confirmed for 2026 and
17+
// the broader set of confederation qualifiers that surface in FotMob payloads
18+
// during the WC 2026 qualifying cycle.
1719
var flagEmojis = map[string]string{
1820
// WC 2022 participants
1921
"QAT": "🇶🇦",
@@ -114,6 +116,53 @@ var flagEmojis = map[string]string{
114116
"BHR": "🇧🇭",
115117
"KUW": "🇰🇼",
116118
"NZL": "🇳🇿",
119+
// WC 2026 confirmed qualifiers / strong qualifying candidates not in WC 2022.
120+
"UZB": "🇺🇿", // Uzbekistan — first-ever WC qualifier (AFC)
121+
"CPV": "🇨🇻", // Cape Verde — first-ever WC qualifier (CAF)
122+
"CUW": "🇨🇼", // Curaçao (CONCACAF)
123+
"HAI": "🇭🇹", // Haiti
124+
"SUR": "🇸🇷", // Suriname
125+
"NCL": "🇳🇨", // New Caledonia (OFC playoff candidate)
126+
"DOM": "🇩🇴", // Dominican Republic
127+
"GUA": "🇬🇹", // Guatemala
128+
"SLV": "🇸🇻", // El Salvador
129+
"PRK": "🇰🇵", // North Korea / DPR Korea
130+
// CAF coverage useful in 2026 qualifying rounds and intercontinental playoffs.
131+
"BFA": "🇧🇫", // Burkina Faso
132+
"ETH": "🇪🇹", // Ethiopia
133+
"GAB": "🇬🇦", // Gabon
134+
"LBY": "🇱🇾", // Libya
135+
"NIG": "🇳🇪", // Niger
136+
"MAD": "🇲🇬", // Madagascar
137+
"MOZ": "🇲🇿", // Mozambique
138+
"ANG": "🇦🇴", // Angola
139+
"ZAM": "🇿🇲", // Zambia
140+
"SLE": "🇸🇱", // Sierra Leone
141+
"EQG": "🇬🇶", // Equatorial Guinea
142+
"BEN": "🇧🇯", // Benin
143+
"TOG": "🇹🇬", // Togo
144+
"COM": "🇰🇲", // Comoros
145+
"SDN": "🇸🇩", // Sudan
146+
"MTN": "🇲🇷", // Mauritania
147+
"NAM": "🇳🇦", // Namibia
148+
"BOT": "🇧🇼", // Botswana
149+
"RWA": "🇷🇼", // Rwanda
150+
// AFC / UEFA tail coverage for qualifying rounds and FotMob's broader payload.
151+
"KAZ": "🇰🇿", // Kazakhstan
152+
"TJK": "🇹🇯", // Tajikistan
153+
"KGZ": "🇰🇬", // Kyrgyzstan
154+
"TKM": "🇹🇲", // Turkmenistan
155+
"LUX": "🇱🇺", // Luxembourg
156+
"CYP": "🇨🇾", // Cyprus
157+
"MLT": "🇲🇹", // Malta
158+
"LVA": "🇱🇻", // Latvia
159+
"LTU": "🇱🇹", // Lithuania
160+
"EST": "🇪🇪", // Estonia
161+
"MDA": "🇲🇩", // Moldova
162+
"BLR": "🇧🇾", // Belarus
163+
"FRO": "🇫🇴", // Faroe Islands
164+
"LIE": "🇱🇮", // Liechtenstein
165+
"RUS": "🇷🇺", // Russia
117166
// Common alternate codes
118167
"HOL": "🇳🇱", // Netherlands alternate
119168
"GBR": "🇬🇧",

internal/ui/worldcup/team_label.go

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ import (
1010
// "<flag-emoji> <CODE>", where <CODE> is the FIFA 3-letter abbreviation.
1111
//
1212
// The code resolution chain is:
13-
// 1. team.ShortName, if non-empty (already a 3-letter code from FotMob).
13+
// 1. team.ShortName, if non-empty AND it resolves to a registered flag.
14+
// This guards against FotMob shipping a non-FIFA shortName (e.g. "SOU"
15+
// for both "South Africa" and "South Korea") that would otherwise mask
16+
// the correct FIFA code.
1417
// 2. A WC-local Name → code override map for known mismatches where FotMob
15-
// ships the full English name without a short code (e.g. "Netherlands" → "NED").
16-
// 3. A deterministic fallback: uppercase the first 3 letters of the name with
17-
// spaces stripped (e.g. "Cape Verde" → "CAP"). This is never ideal but
18-
// keeps every cell aligned even when we receive an unknown country.
18+
// ships the full English name without a populated short code
19+
// (e.g. "Netherlands" → "NED") or ships an ambiguous short code.
20+
// 3. team.ShortName as-is (no flag) when the override map has no match.
21+
// 4. A deterministic fallback: uppercase the first 3 letters of the name
22+
// with spaces stripped (e.g. "Cape Verde" → "CAP"). This is never ideal
23+
// but keeps every cell aligned even when we receive an unknown country.
1924
//
2025
// When no flag emoji is registered for the resolved code, the emoji slot is
2126
// padded with two spaces so that columns stay aligned across rows.
@@ -42,13 +47,22 @@ func MatchupTeamLabel(short, full string, tbd bool) string {
4247
// teamCode resolves a team to its canonical 3-letter code using the chain
4348
// described on TeamLabel. The returned code is always truncated to at most
4449
// three characters so every WC view renders teams in the same column width.
50+
//
51+
// ShortName is preferred only when it resolves to a registered flag emoji.
52+
// This guards against FotMob occasionally shipping a non-FIFA shortName
53+
// (e.g. "SOU" for both "South Africa" and "South Korea") that would
54+
// otherwise mask the correct FIFA code available in wcNameToCode.
4555
func teamCode(short, full string) string {
46-
if c := strings.ToUpper(strings.TrimSpace(short)); c != "" {
47-
return capCode(c)
56+
capped := capCode(strings.ToUpper(strings.TrimSpace(short)))
57+
if capped != "" && FlagEmoji(capped) != "" {
58+
return capped
4859
}
4960
if c, ok := wcNameToCode[strings.ToLower(strings.TrimSpace(full))]; ok {
5061
return capCode(c)
5162
}
63+
if capped != "" {
64+
return capped
65+
}
5266
stripped := strings.ToUpper(strings.ReplaceAll(full, " ", ""))
5367
return capCode(stripped)
5468
}
@@ -196,4 +210,55 @@ var wcNameToCode = map[string]string{
196210
"france": "FRA",
197211
"italy": "ITA",
198212
"tunisia": "TUN",
213+
// WC 2026 confirmed qualifiers / strong qualifying candidates not in WC 2022.
214+
"uzbekistan": "UZB",
215+
"cape verde": "CPV",
216+
"cabo verde": "CPV",
217+
"curacao": "CUW",
218+
"curaçao": "CUW",
219+
"haiti": "HAI",
220+
"suriname": "SUR",
221+
"new caledonia": "NCL",
222+
"dominican republic": "DOM",
223+
"guatemala": "GUA",
224+
"el salvador": "SLV",
225+
"dpr korea": "PRK",
226+
"korea dpr": "PRK",
227+
// CAF coverage useful in 2026 qualifying rounds and intercontinental playoffs.
228+
"burkina faso": "BFA",
229+
"ethiopia": "ETH",
230+
"gabon": "GAB",
231+
"libya": "LBY",
232+
"niger": "NIG",
233+
"madagascar": "MAD",
234+
"mozambique": "MOZ",
235+
"angola": "ANG",
236+
"zambia": "ZAM",
237+
"sierra leone": "SLE",
238+
"equatorial guinea": "EQG",
239+
"benin": "BEN",
240+
"togo": "TOG",
241+
"comoros": "COM",
242+
"sudan": "SDN",
243+
"mauritania": "MTN",
244+
"namibia": "NAM",
245+
"botswana": "BOT",
246+
"rwanda": "RWA",
247+
// AFC / UEFA tail coverage for qualifying rounds.
248+
"kazakhstan": "KAZ",
249+
"tajikistan": "TJK",
250+
"kyrgyzstan": "KGZ",
251+
"turkmenistan": "TKM",
252+
"luxembourg": "LUX",
253+
"cyprus": "CYP",
254+
"malta": "MLT",
255+
"latvia": "LVA",
256+
"lithuania": "LTU",
257+
"estonia": "EST",
258+
"moldova": "MDA",
259+
"belarus": "BLR",
260+
"faroe islands": "FRO",
261+
"liechtenstein": "LIE",
262+
"russia": "RUS",
263+
"israel": "ISR",
199264
}

internal/ui/worldcup/team_label_test.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ func TestTeamLabel(t *testing.T) {
1111
argFlag := FlagEmoji("ARG")
1212
nedFlag := FlagEmoji("NED")
1313
ausFlag := FlagEmoji("AUS")
14-
if argFlag == "" || nedFlag == "" || ausFlag == "" {
15-
t.Fatal("expected ARG/NED/AUS to have flag emojis registered")
14+
korFlag := FlagEmoji("KOR")
15+
rsaFlag := FlagEmoji("RSA")
16+
if argFlag == "" || nedFlag == "" || ausFlag == "" || korFlag == "" || rsaFlag == "" {
17+
t.Fatal("expected ARG/NED/AUS/KOR/RSA to have flag emojis registered")
1618
}
1719

1820
tests := []struct {
@@ -60,6 +62,16 @@ func TestTeamLabel(t *testing.T) {
6062
team: api.Team{Name: "Australia", ShortName: "AUST"},
6163
want: ausFlag + " AUS", // "AUST" → "AUS" → flag resolves
6264
},
65+
{
66+
name: "ambiguous shortname without flag falls back to name override (KOR)",
67+
team: api.Team{Name: "South Korea", ShortName: "SOU"},
68+
want: korFlag + " KOR",
69+
},
70+
{
71+
name: "ambiguous shortname without flag falls back to name override (RSA)",
72+
team: api.Team{Name: "South Africa", ShortName: "SOU"},
73+
want: rsaFlag + " RSA",
74+
},
6375
}
6476

6577
for _, tt := range tests {
@@ -116,3 +128,44 @@ func TestTeamLabel_AlwaysContainsCode(t *testing.T) {
116128
}
117129
}
118130
}
131+
132+
// TestTeamLabel_WC2026Qualifiers asserts a representative sample of teams
133+
// added for the WC 2026 qualifying cycle resolves to "<flag> <CODE>". This
134+
// guards against either the flagEmojis or wcNameToCode entries being
135+
// silently removed.
136+
func TestTeamLabel_WC2026Qualifiers(t *testing.T) {
137+
cases := []struct {
138+
name string
139+
code string
140+
}{
141+
{"Uzbekistan", "UZB"},
142+
{"Cape Verde", "CPV"},
143+
{"Curaçao", "CUW"},
144+
{"Curacao", "CUW"},
145+
{"Haiti", "HAI"},
146+
{"Suriname", "SUR"},
147+
{"New Caledonia", "NCL"},
148+
{"Dominican Republic", "DOM"},
149+
{"Guatemala", "GUA"},
150+
{"El Salvador", "SLV"},
151+
{"North Korea", "PRK"},
152+
{"Burkina Faso", "BFA"},
153+
{"Madagascar", "MAD"},
154+
{"Kazakhstan", "KAZ"},
155+
{"Luxembourg", "LUX"},
156+
{"Israel", "ISR"},
157+
}
158+
for _, tc := range cases {
159+
t.Run(tc.name, func(t *testing.T) {
160+
flag := FlagEmoji(tc.code)
161+
if flag == "" {
162+
t.Fatalf("missing flagEmojis entry for %s (%s)", tc.name, tc.code)
163+
}
164+
got := TeamLabel(api.Team{Name: tc.name})
165+
want := flag + " " + tc.code
166+
if got != want {
167+
t.Errorf("TeamLabel({Name:%q}) = %q, want %q", tc.name, got, want)
168+
}
169+
})
170+
}
171+
}

0 commit comments

Comments
 (0)