Skip to content

Commit b4033a0

Browse files
authored
Merge pull request #154 from sillsdev/BL-15916-retired-iso-code-duplicates
fix: don't create duplicate languages from retired ISO 639-3 codes (BL-15916)
2 parents 21b1e6e + 6b43348 commit b4033a0

5 files changed

Lines changed: 116 additions & 7 deletions

File tree

components/language-chooser/common/find-language/language-data/languageData.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

components/language-chooser/common/find-language/languageSearch.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,3 +622,65 @@ describe("canonical tag is first in alternativeTags", () => {
622622
expect(ojg?.alternativeTags[0].split("-")[0]).toBe("oj");
623623
});
624624
});
625+
626+
// langtags.json sometimes keeps a code that ISO has retired in an entry's iso639_3 field even
627+
// though the entry's own tag already uses the surviving code, e.g. tag "enm-Latn-IE" with
628+
// iso639_3 "yol". Such an entry must fold into the surviving code rather than becoming a
629+
// duplicate language of its own. See the comment in scripts/langtagProcessing.ts and BL-15916.
630+
describe("entries whose langtags iso639_3 code has been retired by ISO", () => {
631+
// [retired code, surviving code the tag uses, exonym of the surviving language]
632+
const retiredToSurviving = [
633+
["yol", "enm", "Middle English (1100-1500)"], // Yola; the surviving code is a *different* language
634+
["kpp", "jkp", "Paku Karen"],
635+
["jeg", "oyb", "Oy"],
636+
["dek", "sqm", "Suma"],
637+
["tpw", "tpn", "Tupinambá"],
638+
];
639+
640+
it("should fold into the surviving language instead of becoming a duplicate, and stay findable by the retired code", () => {
641+
for (const [retired, surviving, exonym] of retiredToSurviving) {
642+
expect(
643+
getLanguageBySubtag(retired),
644+
`${retired} is retired and should not be a language entry of its own`
645+
).toBeUndefined();
646+
647+
const survivor = getLanguageBySubtag(surviving);
648+
expect(survivor, `${surviving} should be a language entry`).toBeDefined();
649+
expect(survivor?.exonym).toBe(exonym);
650+
expect(
651+
survivor?.alternativeTags,
652+
`the retired tag ${retired} should have folded into ${surviving}`
653+
).toContain(retired);
654+
// Note this is the raw search. For yol/enm in particular the surviving language is then
655+
// filtered out by defaultSearchResultModifier (enm is on the historic-language exclusion
656+
// list), so a user searching "yol" sees nothing - that is intended, see the next test.
657+
expect(
658+
searchForLanguage(retired).some((result) =>
659+
codeMatches(result.iso639_3_code, surviving)
660+
),
661+
`the raw search for retired code ${retired} should still reach ${surviving}`
662+
).toBe(true);
663+
}
664+
});
665+
666+
it("should not offer Yola's entry as a selectable Middle English card", () => {
667+
// Regression guard for the user-visible symptom: langtags' "enm-Latn-IE" entry (iso639_3
668+
// "yol") used to produce a card reading "Middle English (1100-1500) - A language of Ireland"
669+
// carrying the subtag enm. That mislabelled Yola, would have written the tag enm onto a
670+
// collection, and slipped past the deliberate enm exclusion in
671+
// defaultExcludedHistoricLanguages.ts because the entry was keyed "yol".
672+
const searchString = "Middle English";
673+
const results = defaultSearchResultModifier(
674+
searchForLanguage(searchString),
675+
searchString
676+
);
677+
// The yol assertion is the one carrying the regression guard; enm is additionally covered by
678+
// the historic-language exclusion, so assert both to pin the whole user-visible outcome.
679+
for (const code of ["yol", "enm"]) {
680+
expect(
681+
results.some((result) => codeMatches(result.iso639_3_code, code)),
682+
`${code} should not be offered as a language option`
683+
).toBe(false);
684+
}
685+
});
686+
});

components/language-chooser/common/find-language/scripts/langtagProcessing.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
stripMacrolanguageParenthetical,
1616
stripMacrolanguageParentheticalFromAll,
1717
iso639_1To639_3,
18+
isCurrentIsoCode,
1819
} from "./langtagProcessingHelpers";
1920

2021
import fs from "fs";
@@ -142,6 +143,36 @@ function parseLangtagsJson() {
142143
}
143144
}
144145

146+
// langtags.json sometimes keeps a code ISO has since retired in the iso639_3 field even
147+
// though the entry's own tag already uses the surviving code, e.g. tag "enm-Latn-IE" with
148+
// iso639_3 "yol", or tag "jkp-Zyyy-MM" with iso639_3 "kpp". Filing such an entry under the
149+
// retired code creates a second, duplicate card for a language we already list under its
150+
// current code. Worse, when the surviving code belongs to a *different* language the
151+
// duplicate also inherits the wrong name and subtag: "yol" (Yola) was showing up as
152+
// "Middle English (1100-1500) - A language of Ireland" with subtag "enm", which would write
153+
// the tag enm onto a collection and slipped past the deliberate enm historic-language
154+
// exclusion (see defaultExcludedHistoricLanguages.ts). So when the iso639_3 code is no
155+
// longer in iso-639-3.tab but the tag's language subtag is, trust the tag. Entries whose
156+
// retired code matches their own tag (e.g. "aoh"/Arma) are left alone - there is no
157+
// surviving code to fold them into. See BL-15916.
158+
//
159+
// Caveat for whoever regenerates this data next: addOrCombineLangtagsEntry does not
160+
// overwrite `exonym` on the combine path, so a folded pair keeps whichever entry langtags
161+
// happened to list first. For all five current pairs both entries carry the same `name`, so
162+
// it makes no difference. But if a future retired/surviving pair had *differing* names and
163+
// langtags listed the retired one first, the merged card would inherit the retired
164+
// language's name - the very symptom this block exists to prevent. Worth re-checking the
165+
// folded entries' exonyms after a langtags update rather than assuming ordering holds.
166+
const subtagIso639_3 = iso639_1To639_3[languageSubtag] || languageSubtag;
167+
if (
168+
augmentedEntry.iso639_3 &&
169+
subtagIso639_3 !== augmentedEntry.iso639_3 &&
170+
!isCurrentIsoCode(augmentedEntry.iso639_3) &&
171+
isCurrentIsoCode(subtagIso639_3)
172+
) {
173+
augmentedEntry.iso639_3 = subtagIso639_3;
174+
}
175+
145176
// If listed with a macrolanguage code, this is a "representative language", we need to identify it by its equivalent
146177
// individual language code. See macrolanguageNotes.md
147178
if (isMacrolanguage(entry.iso639_3) || isMacrolanguage(languageSubtag)) {

components/language-chooser/common/find-language/scripts/langtagProcessingHelpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,13 @@ export function isMacrolanguage(iso639_3: string) {
246246
return isoCodesDetails[iso639_3]?.isMacrolanguage || false;
247247
}
248248

249+
// Is this code listed in the current iso-639-3.tab? Codes that ISO has retired are not,
250+
// even though langtags.json may still refer to them. (Accepts either an ISO 639-1 or an
251+
// ISO 639-3 code, since isoCodesDetails is keyed by both.)
252+
export function isCurrentIsoCode(code: string | undefined) {
253+
return !!code && !!isoCodesDetails[code];
254+
}
255+
249256
// Internal helper used by langtagProcessing.ts for data cleaning. Assumes `langtag` is a canonical BCP-47 tag
250257
function defaultScriptForLanguage(
251258
languageTag: string,

components/language-chooser/react/language-chooser-react-mui/e2e/e2eHelpers.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,28 @@ export function languageCardTestId(languageCode: string) {
2424
// time — letting lazyload mount the newly-visible cards — until the requested card exists, then
2525
// brings it into view. Use this instead of a bare scrollIntoViewIfNeeded when the target card may
2626
// be below the initially-rendered window (e.g. a fuzzy match that isn't near the top).
27+
// Results also arrive in batches, so reaching the bottom of the list does NOT mean the card is
28+
// absent — more results may still be on their way. Sweeping only once and giving up at the bottom
29+
// made this flaky under load, so keep sweeping from the top until the card mounts or we run out of
30+
// time, and let the caller's expect() report the failure if it never does.
31+
//
32+
// Consequence: this is for asserting a card IS there. Don't use it to assert a card is absent —
33+
// it deliberately burns the full timeout below before returning an empty locator.
34+
const SCROLL_FOR_CARD_TIMEOUT_MS = 15000;
2735
export async function scrollListToLanguageCard(page, isoCode: string) {
2836
const card = page.getByTestId(languageCardTestId(isoCode));
2937
const list = page.locator("#language-card-list");
30-
for (let i = 0; i < 40; i++) {
31-
if ((await card.count()) > 0) break;
32-
const movedDown = await list.evaluate((el: HTMLElement) => {
38+
const giveUpAt = Date.now() + SCROLL_FOR_CARD_TIMEOUT_MS;
39+
while ((await card.count()) === 0 && Date.now() < giveUpAt) {
40+
const reachedBottom = await list.evaluate((el: HTMLElement) => {
3341
const before = el.scrollTop;
3442
el.scrollBy(0, Math.max(1, el.clientHeight - 40));
35-
return el.scrollTop > before;
43+
if (el.scrollTop > before) return false;
44+
el.scrollTop = 0; // at the bottom; sweep again in case more results have arrived since
45+
return true;
3646
});
3747
// Let react-lazyload (and any still-streaming search results) render the newly-visible cards.
38-
await page.waitForTimeout(150);
39-
if (!movedDown) break; // reached the bottom of the list
48+
await page.waitForTimeout(reachedBottom ? 300 : 150);
4049
}
4150
await card.scrollIntoViewIfNeeded();
4251
return card;

0 commit comments

Comments
 (0)