Bug 1321789 - Support Unicode extensions with multiple value subtags in BCP47 language tags. r=Waldo
authorAndré Bargull <andre.bargull@gmail.com>
Thu, 04 May 2017 05:04:14 -0700
changeset 572822 c07319097f3b76aa6fe758a0246d2d2958eea717
parent 572821 08d888f39c153a5fd8c2aee35f4d1f609682ec92
child 572823 7ccc011304aa188aa50651cbb148d0f6b09ad495
push id57195
push userbmo:rbarker@mozilla.com
push dateThu, 04 May 2017 20:08:56 +0000
reviewersWaldo
bugs1321789
milestone55.0a1
Bug 1321789 - Support Unicode extensions with multiple value subtags in BCP47 language tags. r=Waldo
js/src/builtin/Intl.cpp
js/src/builtin/Intl.js
js/src/tests/Intl/DateTimeFormat/calendar-aliases.js
js/src/tests/Intl/DateTimeFormat/islamic.js
--- a/js/src/builtin/Intl.cpp
+++ b/js/src/builtin/Intl.cpp
@@ -78,18 +78,18 @@ using mozilla::RangedPtr;
 #if !ENABLE_INTL_API
 
 /*
  * When the Internationalization API isn't enabled, we also shouldn't link
  * against ICU. However, we still want to compile this code in order to prevent
  * bit rot. The following stub implementations for ICU functions make this
  * possible. The functions using them should never be called, so they assert
  * and return error codes. Signatures adapted from ICU header files locid.h,
- * numsys.h, ucal.h, ucol.h, udat.h, udatpg.h, uenum.h, unum.h; see the ICU
- * directory for license.
+ * numsys.h, ucal.h, ucol.h, udat.h, udatpg.h, uenum.h, unum.h, uloc.h;
+ * see the ICU directory for license.
  */
 
 namespace {
 
 enum UErrorCode {
     U_ZERO_ERROR,
     U_BUFFER_OVERFLOW_ERROR,
 };
@@ -826,16 +826,22 @@ u_strToLower(UChar* dest, int32_t destCa
 
 int32_t
 u_strToUpper(UChar* dest, int32_t destCapacity, const UChar* src, int32_t srcLength,
              const char* locale, UErrorCode* pErrorCode)
 {
     MOZ_CRASH("u_strToUpper: Intl API disabled");
 }
 
+const char*
+uloc_toUnicodeLocaleType(const char* keyword, const char* value)
+{
+    MOZ_CRASH("uloc_toUnicodeLocaleType: Intl API disabled");
+}
+
 } // anonymous namespace
 
 #endif
 
 
 /******************** Common to Intl constructors ********************/
 
 static bool
@@ -882,42 +888,40 @@ LegacyIntlInitialize(JSContext* cx, Hand
         return false;
 
     MOZ_ASSERT(result.isObject(), "Legacy Intl object initializer must return an object");
     return true;
 }
 
 // CountAvailable and GetAvailable describe the signatures used for ICU API
 // to determine available locales for various functionality.
-typedef int32_t
-(* CountAvailable)();
-
-typedef const char*
-(* GetAvailable)(int32_t localeIndex);
+using CountAvailable = int32_t (*)();
+using GetAvailable = const char* (*)(int32_t localeIndex);
 
 static bool
 intl_availableLocales(JSContext* cx, CountAvailable countAvailable,
                       GetAvailable getAvailable, MutableHandleValue result)
 {
     RootedObject locales(cx, NewObjectWithGivenProto<PlainObject>(cx, nullptr));
     if (!locales)
         return false;
 
 #if ENABLE_INTL_API
+    RootedAtom a(cx);
     uint32_t count = countAvailable();
     RootedValue t(cx, BooleanValue(true));
     for (uint32_t i = 0; i < count; i++) {
         const char* locale = getAvailable(i);
         auto lang = DuplicateString(cx, locale);
         if (!lang)
             return false;
         char* p;
         while ((p = strchr(lang.get(), '_')))
             *p = '-';
-        RootedAtom a(cx, Atomize(cx, lang.get(), strlen(lang.get())));
+        a = Atomize(cx, lang.get(), strlen(lang.get()));
         if (!a)
             return false;
         if (!DefineProperty(cx, locales, a->asPropertyName(), t, nullptr, nullptr,
                             JSPROP_ENUMERATE))
         {
             return false;
         }
     }
@@ -1206,45 +1210,37 @@ js::intl_availableCollations(JSContext* 
 
     uint32_t index = 0;
 
     // The first element of the collations array must be |null| per
     // ES2017 Intl, 10.2.3 Internal Slots.
     if (!DefineElement(cx, collations, index++, NullHandleValue))
         return false;
 
+    RootedString jscollation(cx);
+    RootedValue element(cx);
     for (uint32_t i = 0; i < count; i++) {
         const char* collation = uenum_next(values, nullptr, &status);
         if (U_FAILURE(status)) {
             JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr, JSMSG_INTERNAL_INTL_ERROR);
             return false;
         }
 
         // Per ECMA-402, 10.2.3, we don't include standard and search:
         // "The values 'standard' and 'search' must not be used as elements in
         // any [[sortLocaleData]][locale].co and [[searchLocaleData]][locale].co
         // array."
         if (equal(collation, "standard") || equal(collation, "search"))
             continue;
 
-        // ICU returns old-style keyword values; map them to BCP 47 equivalents
-        // (see http://bugs.icu-project.org/trac/ticket/9620).
-        if (equal(collation, "dictionary"))
-            collation = "dict";
-        else if (equal(collation, "gb2312han"))
-            collation = "gb2312";
-        else if (equal(collation, "phonebook"))
-            collation = "phonebk";
-        else if (equal(collation, "traditional"))
-            collation = "trad";
-
-        RootedString jscollation(cx, JS_NewStringCopyZ(cx, collation));
+        // ICU returns old-style keyword values; map them to BCP 47 equivalents.
+        jscollation = JS_NewStringCopyZ(cx, uloc_toUnicodeLocaleType("co", collation));
         if (!jscollation)
             return false;
-        RootedValue element(cx, StringValue(jscollation));
+        element = StringValue(jscollation);
         if (!DefineElement(cx, collations, index++, element))
             return false;
     }
 
     args.rval().setObject(*collations);
     return true;
 }
 
@@ -2673,29 +2669,26 @@ js::intl_DateTimeFormat_availableLocales
 
     RootedValue result(cx);
     if (!intl_availableLocales(cx, udat_countAvailable, udat_getAvailable, &result))
         return false;
     args.rval().set(result);
     return true;
 }
 
-// ICU returns old-style keyword values; map them to BCP 47 equivalents
-// (see http://bugs.icu-project.org/trac/ticket/9620).
-static const char*
-bcp47CalendarName(const char* icuName)
+struct CalendarAlias
 {
-    if (equal(icuName, "ethiopic-amete-alem"))
-        return "ethioaa";
-    if (equal(icuName, "gregorian"))
-        return "gregory";
-    if (equal(icuName, "islamic-civil"))
-        return "islamicc";
-    return icuName;
-}
+    const char* const calendar;
+    const char* const alias;
+};
+
+const CalendarAlias calendarAliases[] = {
+    { "islamic-civil", "islamicc" },
+    { "ethioaa", "ethiopic-amete-alem" }
+};
 
 bool
 js::intl_availableCalendars(JSContext* cx, unsigned argc, Value* vp)
 {
     CallArgs args = CallArgsFromVp(argc, vp);
     MOZ_ASSERT(args.length() == 1);
     MOZ_ASSERT(args[0].isString());
 
@@ -2718,17 +2711,18 @@ js::intl_availableCalendars(JSContext* c
         ScopedICUObject<UCalendar, ucal_close> closeCalendar(cal);
 
         const char* calendar = ucal_getType(cal, &status);
         if (U_FAILURE(status)) {
             JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr, JSMSG_INTERNAL_INTL_ERROR);
             return false;
         }
 
-        jscalendar = JS_NewStringCopyZ(cx, bcp47CalendarName(calendar));
+        // ICU returns old-style keyword values; map them to BCP 47 equivalents
+        jscalendar = JS_NewStringCopyZ(cx, uloc_toUnicodeLocaleType("ca", calendar));
         if (!jscalendar)
             return false;
     }
 
     RootedValue element(cx, StringValue(jscalendar));
     if (!DefineElement(cx, calendars, index++, element))
         return false;
 
@@ -2748,22 +2742,37 @@ js::intl_availableCalendars(JSContext* c
 
     for (; count > 0; count--) {
         const char* calendar = uenum_next(values, nullptr, &status);
         if (U_FAILURE(status)) {
             JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr, JSMSG_INTERNAL_INTL_ERROR);
             return false;
         }
 
-        jscalendar = JS_NewStringCopyZ(cx, bcp47CalendarName(calendar));
+        // ICU returns old-style keyword values; map them to BCP 47 equivalents
+        calendar = uloc_toUnicodeLocaleType("ca", calendar);
+
+        jscalendar = JS_NewStringCopyZ(cx, calendar);
         if (!jscalendar)
             return false;
         element = StringValue(jscalendar);
         if (!DefineElement(cx, calendars, index++, element))
             return false;
+
+        // ICU doesn't return calendar aliases, append them here.
+        for (const auto& calendarAlias : calendarAliases) {
+            if (equal(calendar, calendarAlias.calendar)) {
+                jscalendar = JS_NewStringCopyZ(cx, calendarAlias.alias);
+                if (!jscalendar)
+                    return false;
+                element = StringValue(jscalendar);
+                if (!DefineElement(cx, calendars, index++, element))
+                    return false;
+            }
+        }
     }
 
     args.rval().setObject(*calendars);
     return true;
 }
 
 template<typename Char>
 static constexpr Char
--- a/js/src/builtin/Intl.js
+++ b/js/src/builtin/Intl.js
@@ -377,17 +377,17 @@ function CanonicalizeLanguageTag(locale)
     // "Zh-NAN-haNS-bu-variant2-Variant1-u-ca-chinese-t-Zh-laTN-x-PRIVATE" ->
     // "zh-nan-hans-bu-variant2-variant1-u-ca-chinese-t-zh-latn-x-private"
     locale = callFunction(std_String_toLowerCase, locale);
 
     // Handle mappings for complete tags.
     if (hasOwn(locale, langTagMappings))
         return langTagMappings[locale];
 
-    var subtags = StringSplitString(ToString(locale), "-");
+    var subtags = StringSplitString(locale, "-");
     var i = 0;
 
     // Handle the standard part: All subtags before the first singleton or "x".
     // "zh-nan-hans-bu-variant2-variant1"
     while (i < subtags.length) {
         var subtag = subtags[i];
 
         // If we reach the start of an extension sequence or private use part,
@@ -925,20 +925,17 @@ function LookupMatcher(availableLocales,
     }
 
     var result = new Record();
     if (availableLocale !== undefined) {
         result.locale = availableLocale;
         if (locale !== noExtensionsLocale) {
             var unicodeLocaleExtensionSequenceRE = getUnicodeLocaleExtensionSequenceRE();
             var extensionMatch = regexp_exec_no_statics(unicodeLocaleExtensionSequenceRE, locale);
-            var extension = extensionMatch[0];
-            var extensionIndex = extensionMatch.index;
-            result.extension = extension;
-            result.extensionIndex = extensionIndex;
+            result.extension = extensionMatch[0];
         }
     } else {
         result.locale = DefaultLocale();
     }
     return result;
 }
 
 
@@ -953,16 +950,89 @@ function LookupMatcher(availableLocales,
  */
 function BestFitMatcher(availableLocales, requestedLocales) {
     // this implementation doesn't have anything better
     return LookupMatcher(availableLocales, requestedLocales);
 }
 
 
 /**
+ * Returns the Unicode extension value subtags for the requested key subtag.
+ *
+ * NOTE: PR to add UnicodeExtensionValue to ECMA-402 isn't yet written.
+ */
+function UnicodeExtensionValue(extension, key) {
+    assert(typeof extension === "string", "extension is a string value");
+    assert(function() {
+        var unicodeLocaleExtensionSequenceRE = getUnicodeLocaleExtensionSequenceRE();
+        var extensionMatch = regexp_exec_no_statics(unicodeLocaleExtensionSequenceRE, extension);
+        return extensionMatch !== null && extensionMatch[0] === extension;
+    }(), "extension is a Unicode extension subtag");
+    assert(typeof key === "string", "key is a string value");
+    assert(key.length === 2, "key is a Unicode extension key subtag");
+
+    // Step 1.
+    var size = extension.length;
+
+    // Step 2.
+    var searchValue = "-" + key + "-";
+
+    // Step 3.
+    var pos = callFunction(std_String_indexOf, extension, searchValue);
+
+    // Step 4.
+    if (pos !== -1) {
+        // Step 4.a.
+        var start = pos + 4;
+
+        // Step 4.b.
+        var end = start;
+
+        // Step 4.c.
+        var k = start;
+
+        // Steps 4.d-e.
+        while (true) {
+            // Step 4.e.i.
+            var e = callFunction(std_String_indexOf, extension, "-", k);
+
+            // Step 4.e.ii.
+            var len = e === -1 ? size - k : e - k;
+
+            // Step 4.e.iii.
+            if (len === 2)
+                break;
+
+            // Step 4.e.iv.
+            if (e === -1) {
+                end = size;
+                break;
+            }
+
+            // Step 4.e.v.
+            end = e;
+            k = e + 1;
+        }
+
+        // Step 4.f.
+        return callFunction(String_substring, extension, start, end);
+    }
+
+    // Step 5.
+    searchValue = "-" + key;
+
+    // Steps 6-7.
+    if (callFunction(std_String_endsWith, extension, searchValue))
+        return "";
+
+    // Step 8 (implicit).
+}
+
+
+/**
  * Compares a BCP 47 language priority list against availableLocales and
  * determines the best available language to meet the request. Options specified
  * through Unicode extension subsequences are negotiated separately, taking the
  * caller's relevant extensions and locale data as well as client-provided
  * options into consideration.
  *
  * Spec: ECMAScript Internationalization API Specification, 9.2.5.
  */
@@ -973,135 +1043,131 @@ function ResolveLocale(availableLocales,
     var matcher = options.localeMatcher;
     var r = (matcher === "lookup")
             ? LookupMatcher(availableLocales, requestedLocales)
             : BestFitMatcher(availableLocales, requestedLocales);
 
     // Step 4.
     var foundLocale = r.locale;
 
-    // Step 5.a.
+    // Step 5 (Not applicable in this implementation).
     var extension = r.extension;
-    var extensionIndex, extensionSubtags, extensionSubtagsLength;
-
-    // Step 5.
-    if (extension !== undefined) {
-        // Step 5.b.
-        extensionIndex = r.extensionIndex;
-
-        // Steps 5.d-e.
-        extensionSubtags = StringSplitString(ToString(extension), "-");
-        extensionSubtagsLength = extensionSubtags.length;
-    }
 
     // Steps 6-7.
     var result = new Record();
     result.dataLocale = foundLocale;
 
     // Step 8.
     var supportedExtension = "-u";
 
-    // Steps 9-11.
+    // Steps 9-12.
     var i = 0;
     var len = relevantExtensionKeys.length;
     var foundLocaleData;
     if (len > 0) {
         // In this implementation, localeData is a function, not an object.
-        // Step 11.b.
+        // Step 12.b.
         foundLocaleData = localeData(foundLocale);
     }
     while (i < len) {
-        // Step 11.a.
+        // Step 12.a.
         var key = relevantExtensionKeys[i];
 
-        // Step 11.c.
+        // Step 12.c.
         var keyLocaleData = foundLocaleData[key];
 
         // Locale data provides default value.
-        // Step 11.d.
+        // Step 12.d.
         var value = keyLocaleData[0];
+        assert(typeof value === "string" || value === null, "unexpected locale data value");
 
         // Locale tag may override.
 
-        // Step 11.e.
+        // Step 12.e.
         var supportedExtensionAddition = "";
 
-        // Step 11.f is implemented by Utilities.js.
-
-        var valuePos;
-
-        // Step 11.g.
-        if (extensionSubtags !== undefined) {
-            // Step 11.g.i.
-            var keyPos = callFunction(ArrayIndexOf, extensionSubtags, key);
-
-            // Step 11.g.ii.
-            if (keyPos !== -1) {
-                // Step 11.g.ii.1.
-                if (keyPos + 1 < extensionSubtagsLength &&
-                    extensionSubtags[keyPos + 1].length > 2)
-                {
-                    // Step 11.g.ii.1.a.
-                    var requestedValue = extensionSubtags[keyPos + 1];
-
-                    // Step 11.g.ii.1.b.
-                    valuePos = callFunction(ArrayIndexOf, keyLocaleData, requestedValue);
-
-                    // Step 11.g.ii.1.c.
-                    if (valuePos !== -1) {
+        // Step 12.f.
+        if (extension !== undefined) {
+            // NB: The step annotations don't yet match the ES2017 Intl draft,
+            // 94045d234762ad107a3d09bb6f7381a65f1a2f9b, because the PR to add
+            // the new UnicodeExtensionValue abstract operation still needs to
+            // be written.
+
+            // Step 12.f.i.
+            var requestedValue = UnicodeExtensionValue(extension, key);
+
+            // Step 12.f.ii.
+            if (requestedValue !== undefined) {
+                // Step 12.f.ii.1.
+                if (requestedValue !== "") {
+                    // Step 12.f.ii.1.a.
+                    if (callFunction(ArrayIndexOf, keyLocaleData, requestedValue) !== -1) {
                         value = requestedValue;
                         supportedExtensionAddition = "-" + key + "-" + value;
                     }
                 } else {
-                    // Step 11.g.ii.2.
+                    // Step 12.f.ii.2.
 
                     // According to the LDML spec, if there's no type value,
                     // and true is an allowed value, it's used.
-
-                    // Step 11.g.ii.2.a.
-                    valuePos = callFunction(ArrayIndexOf, keyLocaleData, "true");
-
-                    // Step 11.g.ii.2.b.
-                    if (valuePos !== -1)
+                    if (callFunction(ArrayIndexOf, keyLocaleData, "true") !== -1)
                         value = "true";
                 }
             }
         }
 
         // Options override all.
 
-        // Step 11.h.i.
+        // Step 12.g.i.
         var optionsValue = options[key];
 
-        // Step 11.h, 11.h.ii.
+        // Step 12.g, 12.g.ii.
         if (optionsValue !== undefined &&
+            optionsValue !== value &&
             callFunction(ArrayIndexOf, keyLocaleData, optionsValue) !== -1)
         {
-            // Step 11.h.ii.1.
-            if (optionsValue !== value) {
-                value = optionsValue;
-                supportedExtensionAddition = "";
-            }
+            value = optionsValue;
+            supportedExtensionAddition = "";
         }
 
-        // Steps 11.i-k.
+        // Steps 12.h-j.
         result[key] = value;
         supportedExtension += supportedExtensionAddition;
         i++;
     }
 
-    // Step 12.
+    // Step 13.
     if (supportedExtension.length > 2) {
-        var preExtension = callFunction(String_substring, foundLocale, 0, extensionIndex);
-        var postExtension = callFunction(String_substring, foundLocale, extensionIndex);
-        foundLocale = preExtension + supportedExtension + postExtension;
+        assert(!callFunction(std_String_startsWith, foundLocale, "x-"),
+               "unexpected privateuse-only locale returned from ICU");
+
+        // Step 13.a.
+        var privateIndex = callFunction(std_String_indexOf, foundLocale, "-x-");
+
+        // Steps 13.b-c.
+        if (privateIndex === -1) {
+            foundLocale += supportedExtension;
+        } else {
+            var preExtension = callFunction(String_substring, foundLocale, 0, privateIndex);
+            var postExtension = callFunction(String_substring, foundLocale, privateIndex);
+            foundLocale = preExtension + supportedExtension + postExtension;
+        }
+
+        // Step 13.d.
+        assert(IsStructurallyValidLanguageTag(foundLocale), "invalid locale after concatenation");
+
+        // Step 13.e (Not required in this implementation, because we don't
+        // canonicalize Unicode extension subtags).
+        assert(foundLocale === CanonicalizeLanguageTag(foundLocale), "same locale with extension");
     }
 
-    // Steps 13-14.
+    // Step 14.
     result.locale = foundLocale;
+
+    // Step 15.
     return result;
 }
 
 
 /**
  * Returns the subset of requestedLocales for which availableLocales has a
  * matching (possibly fallback) locale. Locales appear in the same order in the
  * returned list as in the input list.
new file mode 100644
--- /dev/null
+++ b/js/src/tests/Intl/DateTimeFormat/calendar-aliases.js
@@ -0,0 +1,35 @@
+// |reftest| skip-if(!this.hasOwnProperty("Intl"))
+
+// Ensure ethiopic-amete-alem is resolved to ethioaa instead of ethiopic.
+function testEthiopicAmeteAlem() {
+    var locale = "am-ET-u-nu-latn";
+    var opts = {timeZone: "Africa/Addis_Ababa"};
+    var dtfEthiopicAmeteAlem = new Intl.DateTimeFormat(`${locale}-ca-ethiopic-amete-alem`, opts);
+    var dtfEthioaa = new Intl.DateTimeFormat(`${locale}-ca-ethioaa`, opts);
+    var dtfEthiopic = new Intl.DateTimeFormat(`${locale}-ca-ethiopic`, opts);
+
+    var date = new Date(2016, 1 - 1, 1);
+
+    assertEq(dtfEthiopicAmeteAlem.format(date), dtfEthioaa.format(date));
+    assertEq(dtfEthiopicAmeteAlem.format(date) === dtfEthiopic.format(date), false);
+}
+
+// Ensure islamicc is resolved to islamic-civil.
+function testIslamicCivil() {
+    var locale = "ar-SA-u-nu-latn";
+    var opts = {timeZone: "Asia/Riyadh"};
+    var dtfIslamicCivil = new Intl.DateTimeFormat(`${locale}-ca-islamic-civil`, opts);
+    var dtfIslamicc = new Intl.DateTimeFormat(`${locale}-ca-islamicc`, opts);
+    var dtfIslamic = new Intl.DateTimeFormat(`${locale}-ca-islamic`, opts);
+
+    var date = new Date(2016, 1 - 1, 1);
+
+    assertEq(dtfIslamicCivil.format(date), dtfIslamicc.format(date));
+    assertEq(dtfIslamicCivil.format(date) === dtfIslamic.format(date), false);
+}
+
+testEthiopicAmeteAlem();
+testIslamicCivil();
+
+if (typeof reportCompare === "function")
+    reportCompare(0, 0, "ok");
new file mode 100644
--- /dev/null
+++ b/js/src/tests/Intl/DateTimeFormat/islamic.js
@@ -0,0 +1,89 @@
+// |reftest| skip-if(!this.hasOwnProperty("Intl"))
+
+function civilDate(options, date) {
+    var opts = Object.assign({timeZone: "Asia/Riyadh"}, options);
+    return new Intl.DateTimeFormat("ar-SA-u-ca-islamic-civil-nu-latn", opts).format(date);
+}
+
+function tabularDate(options, date) {
+    var opts = Object.assign({timeZone: "Asia/Riyadh"}, options);
+    return new Intl.DateTimeFormat("ar-SA-u-ca-islamic-tbla-nu-latn", opts).format(date);
+}
+
+function sightingDate(options, date) {
+    var opts = Object.assign({timeZone: "Asia/Riyadh"}, options);
+    return new Intl.DateTimeFormat("ar-SA-u-ca-islamic-rgsa-nu-latn", opts).format(date);
+}
+
+function ummAlQuraDate(options, date) {
+    var opts = Object.assign({timeZone: "Asia/Riyadh"}, options);
+    return new Intl.DateTimeFormat("ar-SA-u-ca-umalqura-nu-latn", opts).format(date);
+}
+
+// Test islamic-tbla (Tabular / Thursday epoch).
+// Compare with islamic-civil (Tabular / Friday epoch).
+function testIslamicTbla() {
+    var date = new Date(Date.UTC(2015, 1 - 1, 1));
+
+    // Month and year are the same.
+    var monthYear = {year: "numeric", month: "numeric"};
+    assertEq(civilDate(monthYear, date), tabularDate(monthYear, date));
+
+    // Day is different by one.
+    var day = {day: "numeric"};
+    assertEq(Number(civilDate(day, date)) - Number(tabularDate(day, date)), -1);
+}
+
+// Test islamic-rgsa (Saudi Arabia sighting).
+// Sighting of the hilal (crescent moon) in Saudi Arabia.
+function testIslamicRgsa() {
+    var date1 = new Date(Date.UTC(1975, 5 - 1, 6));
+    var date2 = new Date(Date.UTC(2015, 1 - 1, 1));
+    var dayMonthYear = {year: "numeric", month: "numeric", day: "numeric"};
+
+    assertEq(sightingDate(dayMonthYear, date1), tabularDate(dayMonthYear, date1));
+    assertEq(sightingDate(dayMonthYear, date2), civilDate(dayMonthYear, date2));
+}
+
+// Test islamic-umalqura (Umm al-Qura).
+function testIslamicUmalqura() {
+    var year = {year: "numeric"};
+    var month = {month: "numeric"};
+    var day = {day: "numeric"};
+
+    // From ICU test files, which in turn was generated from:
+    //   Official Umm-al-Qura calendar of SA:
+    //   home, http://www.ummulqura.org.sa/default.aspx
+    //   converter, http://www.ummulqura.org.sa/Index.aspx
+    var dates = [
+        [ {year: 2016, month:  1, day: 11}, {year: 1437, month:  4, day: 1} ],
+        [ {year: 2016, month:  2, day: 10}, {year: 1437, month:  5, day: 1} ],
+        [ {year: 2016, month:  3, day: 10}, {year: 1437, month:  6, day: 1} ],
+        [ {year: 2016, month:  4, day:  8}, {year: 1437, month:  7, day: 1} ],
+        [ {year: 2016, month:  5, day:  8}, {year: 1437, month:  8, day: 1} ],
+        [ {year: 2016, month:  6, day:  6}, {year: 1437, month:  9, day: 1} ],
+        [ {year: 2016, month:  7, day:  6}, {year: 1437, month: 10, day: 1} ],
+        [ {year: 2016, month:  8, day:  4}, {year: 1437, month: 11, day: 1} ],
+        [ {year: 2016, month:  9, day:  2}, {year: 1437, month: 12, day: 1} ],
+        [ {year: 2016, month: 10, day:  2}, {year: 1438, month:  1, day: 1} ],
+        [ {year: 2016, month: 11, day:  1}, {year: 1438, month:  2, day: 1} ],
+        [ {year: 2016, month: 11, day: 30}, {year: 1438, month:  3, day: 1} ],
+        [ {year: 2016, month: 12, day: 30}, {year: 1438, month:  4, day: 1} ],
+    ];
+
+    for (var [gregorian, ummAlQura] of dates) {
+        var date = new Date(Date.UTC(gregorian.year, gregorian.month - 1, gregorian.day));
+
+        // Use parseInt() to remove the trailing era indicator.
+        assertEq(parseInt(ummAlQuraDate(year, date), 10), ummAlQura.year);
+        assertEq(Number(ummAlQuraDate(month, date)), ummAlQura.month);
+        assertEq(Number(ummAlQuraDate(day, date)), ummAlQura.day);
+    }
+}
+
+testIslamicTbla();
+testIslamicRgsa();
+testIslamicUmalqura();
+
+if (typeof reportCompare === "function")
+    reportCompare(0, 0, "ok");