From 5275f70535390e378a5a2018971b343614759e3c Mon Sep 17 00:00:00 2001 From: kavemang Date: Mon, 28 Sep 2026 01:26:09 -0500 Subject: [PATCH 1/3] fix: retain Unicode numbers in Android lab marker keys Refs #2351 --- .../LabMarkerCsvImportTests.swift | 6 +++--- .../com/noop/ingest/LabMarkerCsvImport.kt | 20 ++++++++++++------- .../com/noop/ingest/LabMarkerCsvImportTest.kt | 8 ++++---- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift index 1634055eaf..f34d110c9c 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift @@ -327,13 +327,13 @@ final class LabMarkerCsvImportTests: XCTestCase { } /// Pinned by oracle: the same literals are asserted by the Android twin - /// (LabMarkerCsvImportTest.customKeyIsPinned). Names without "%" keep their pre-"pct" keys. + /// (LabMarkerCsvImportTest.customKeyIsPinned). The Unicode numeric cases pin #2351 parity. func testCustomKeyIsPinned() { let names = [ - "LYMPH", "LYMPH %", "LYMPH%", "Lymph (%)", "BASO", "BASO %", "% Neutrophils", "NEUT %", "a%%b", "%", "Apo B", "Magnesium", " ??? ", "A - B", "Vitamin D (25-OH)", "Caf\u{e9} Marker", "Cafe\u{301} Marker", "MCHC g/dL", "HbA1c (IFCC)", + "LYMPH", "LYMPH %", "LYMPH%", "Lymph (%)", "BASO", "BASO %", "% Neutrophils", "NEUT %", "a%%b", "%", "Apo B", "Magnesium", " ??? ", "A - B", "Vitamin D (25-OH)", "Caf\u{e9} Marker", "Cafe\u{301} Marker", "MCHC g/dL", "HbA1c (IFCC)", "eGFR mL/min/1.73m²", "Factor Ⅻ", "Vitamin B₁₂", "①%", "½ dose", "³", "2", "𐄇", ] let expected = [ - "custom_lymph", "custom_lymph_pct", "custom_lymph_pct", "custom_lymph_pct", "custom_baso", "custom_baso_pct", "custom_pct_neutrophils", "custom_neut_pct", "custom_a_pctpct_b", "custom_pct", "custom_apo_b", "custom_magnesium", "", "custom_a__b", "custom_vitamin_d_25_oh", "custom_café_marker", "custom_café_marker", "custom_mchc_g_dl", "custom_hba1c_ifcc", + "custom_lymph", "custom_lymph_pct", "custom_lymph_pct", "custom_lymph_pct", "custom_baso", "custom_baso_pct", "custom_pct_neutrophils", "custom_neut_pct", "custom_a_pctpct_b", "custom_pct", "custom_apo_b", "custom_magnesium", "", "custom_a__b", "custom_vitamin_d_25_oh", "custom_café_marker", "custom_café_marker", "custom_mchc_g_dl", "custom_hba1c_ifcc", "custom_egfr_ml_min_1_73m²", "custom_factor_ⅻ", "custom_vitamin_b₁₂", "custom_①_pct", "custom_½_dose", "custom_³", "custom_2", "custom_𐄇", ] XCTAssertEqual(names.map(LabMarkerCsvImport.customKey), expected) } diff --git a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt index 91fb06df92..23b2ca8a8e 100644 --- a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt +++ b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt @@ -453,19 +453,25 @@ object LabMarkerCsvImport { * other punctuation gave both `custom_lymph`, so the second row of the day overwrote the * first. The token is joined to a letter/digit neighbour by one "_" (a non-alphanumeric * neighbour already maps to "_"), so "LYMPH %", "LYMPH%" and "Lymph (%)" all give - * `custom_lymph_pct`. Names without "%" keep the keys they always had. Byte-identical to - * Swift `LabMarkerCsvImport.customKey`. + * `custom_lymph_pct`. Names without "%" keep their keys except those containing Unicode + * number letters or other numbers, which Android previously replaced with underscores (#2351). + * Byte-identical to Swift `LabMarkerCsvImport.customKey`. */ internal fun customKey(name: String): String { val lowered = Normalizer.normalize(name, Normalizer.Form.NFC).trim().lowercase() + // Swift Character.isNumber also keeps Unicode letter/other numbers (e.g. Ⅻ, ²). + fun isWordChar(cp: Int): Boolean = Character.isLetterOrDigit(cp) || + Character.getType(cp) == Character.LETTER_NUMBER.toInt() || + Character.getType(cp) == Character.OTHER_NUMBER.toInt() + val chars = lowered.codePoints().toArray() val mapped = buildString { - lowered.forEachIndexed { i, ch -> - if (ch == '%') { - if (i > 0 && lowered[i - 1].isLetterOrDigit()) append('_') + chars.forEachIndexed { i, ch -> + if (ch == '%'.code) { + if (i > 0 && isWordChar(chars[i - 1])) append('_') append("pct") - if (i + 1 < lowered.length && lowered[i + 1].isLetterOrDigit()) append('_') + if (i + 1 < chars.size && isWordChar(chars[i + 1])) append('_') } else { - append(if (ch.isLetterOrDigit()) ch else '_') + if (isWordChar(ch)) appendCodePoint(ch) else append('_') } } } diff --git a/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt b/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt index 65f21b655b..3d128247a2 100644 --- a/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt +++ b/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt @@ -346,15 +346,15 @@ class LabMarkerCsvImportTest { /** * Expected literals are the verbatim stdout of Swift `LabMarkerCsvImport.customKey` compiled - * standalone over [names] (the Swift suite pins the same list). Names without "%" keep their - * pre-"pct" keys. + * standalone over [names] (the Swift suite pins the same list). The Unicode numeric cases + * guard the Android/Swift category boundary from #2351. */ @Test fun customKeyIsPinned() { val names = listOf( - "LYMPH", "LYMPH %", "LYMPH%", "Lymph (%)", "BASO", "BASO %", "% Neutrophils", "NEUT %", "a%%b", "%", "Apo B", "Magnesium", " ??? ", "A - B", "Vitamin D (25-OH)", "Caf\u00e9 Marker", "Cafe\u0301 Marker", "MCHC g/dL", "HbA1c (IFCC)", + "LYMPH", "LYMPH %", "LYMPH%", "Lymph (%)", "BASO", "BASO %", "% Neutrophils", "NEUT %", "a%%b", "%", "Apo B", "Magnesium", " ??? ", "A - B", "Vitamin D (25-OH)", "Caf\u00e9 Marker", "Cafe\u0301 Marker", "MCHC g/dL", "HbA1c (IFCC)", "eGFR mL/min/1.73m²", "Factor Ⅻ", "Vitamin B₁₂", "①%", "½ dose", "³", "2", "𐄇", ) val expected = listOf( - "custom_lymph", "custom_lymph_pct", "custom_lymph_pct", "custom_lymph_pct", "custom_baso", "custom_baso_pct", "custom_pct_neutrophils", "custom_neut_pct", "custom_a_pctpct_b", "custom_pct", "custom_apo_b", "custom_magnesium", "", "custom_a__b", "custom_vitamin_d_25_oh", "custom_café_marker", "custom_café_marker", "custom_mchc_g_dl", "custom_hba1c_ifcc", + "custom_lymph", "custom_lymph_pct", "custom_lymph_pct", "custom_lymph_pct", "custom_baso", "custom_baso_pct", "custom_pct_neutrophils", "custom_neut_pct", "custom_a_pctpct_b", "custom_pct", "custom_apo_b", "custom_magnesium", "", "custom_a__b", "custom_vitamin_d_25_oh", "custom_café_marker", "custom_café_marker", "custom_mchc_g_dl", "custom_hba1c_ifcc", "custom_egfr_ml_min_1_73m²", "custom_factor_ⅻ", "custom_vitamin_b₁₂", "custom_①_pct", "custom_½_dose", "custom_³", "custom_2", "custom_𐄇", ) assertEquals(expected, names.map { LabMarkerCsvImport.customKey(it) }) } From 67b25ecf959039c42051ea9ec006d19887dc1b30 Mon Sep 17 00:00:00 2001 From: kavemang Date: Wed, 30 Sep 2026 02:00:47 -0500 Subject: [PATCH 2/3] Pair lab marker word helpers in parity ledger (Refs #2351) --- .../Sources/StrandImport/LabMarkerCsvImport.swift | 1 + .../app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift b/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift index 795d1476b3..162b41619b 100644 --- a/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift +++ b/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift @@ -356,6 +356,7 @@ public enum LabMarkerCsvImport { public static func customKey(_ name: String) -> String { let lowered = Array(name.precomposedStringWithCanonicalMapping .trimmingCharacters(in: .whitespaces).lowercased()) + // Kotlin twin: `LabMarkerCsvImport.isWordChar`. func isWordChar(_ ch: Character) -> Bool { ch.isLetter || ch.isNumber } var mapped = "" for (i, ch) in lowered.enumerated() { diff --git a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt index 23b2ca8a8e..ea48e12788 100644 --- a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt +++ b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt @@ -455,11 +455,13 @@ object LabMarkerCsvImport { * neighbour already maps to "_"), so "LYMPH %", "LYMPH%" and "Lymph (%)" all give * `custom_lymph_pct`. Names without "%" keep their keys except those containing Unicode * number letters or other numbers, which Android previously replaced with underscores (#2351). - * Byte-identical to Swift `LabMarkerCsvImport.customKey`. + * Swift twin: `LabMarkerCsvImport.customKey`. NFC-composable names produce the same key; + * Swift's grapheme clusters and Kotlin's code points can differ for uncomposed marks. */ internal fun customKey(name: String): String { val lowered = Normalizer.normalize(name, Normalizer.Form.NFC).trim().lowercase() // Swift Character.isNumber also keeps Unicode letter/other numbers (e.g. Ⅻ, ²). + // Swift twin: `LabMarkerCsvImport.isWordChar`. fun isWordChar(cp: Int): Boolean = Character.isLetterOrDigit(cp) || Character.getType(cp) == Character.LETTER_NUMBER.toInt() || Character.getType(cp) == Character.OTHER_NUMBER.toInt() From e52d5b7058d2ed9501f7b278e43ba15b7711d686 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 2 Oct 2026 14:40:47 +1300 Subject: [PATCH 3/3] parity: regenerate the derived inventory after rebasing the lab-marker keys The branch carried its own snapshot of Tools/parity_twin_map.json, taken before the declaration cleanup landed on main. Both edited the same counts, so the rebase conflicted there. A map is DERIVED, so the resolution is to regenerate it rather than to pick a side or merge the JSON by hand: either of those produces counts that describe neither tree. Resolved by taking main's snapshot and re-running --refresh-derived over the rebased source, which reproduces cleanly. Metadata only. The lab-marker change itself is unchanged and stays authored by its contributor. --- Tools/parity_twin_map.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 99761d5a92..37807516a9 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 507, "sha256": "efc6950759de76273adccde8b6457f5e8577bdaf6ed9c6538cde79b0eaf0c612"}, - "functions": {"count": 4517, "sha256": "05dc56778990d1a6313fc8429b783110ef534ebe49693d91ab7da9c736df040c"}, + "functions": {"count": 4518, "sha256": "f424a4e32aceb96b06b2dc790e858bb55b2548fb326c1be1cfb85c6308d013aa"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, "constants": {"count": 1985, "sha256": "0c67e7c378db2c0518339f073b37967e30cec0d5b586c1408841ca5e7977f9b4"}, - "file_pairs": {"count": 78, "sha256": "ec0d88b4e0535c3b1051b5c10205f21174944018669fbe3479a3c21801a6dc38"}, - "function_pairs": {"count": 215, "sha256": "f4ffaafbdf720d27594002cc2337a7b1be5635ec8d02720b7722f01656e8720b"}, + "file_pairs": {"count": 79, "sha256": "02ee127080cf87c98925a60b30b6e87d25e7a82ef6cf43b6251fe24c20600dd1"}, + "function_pairs": {"count": 217, "sha256": "c31664b757a428dcadbf56068d92c681397dc2f378e5e6d6ea65be6e70c3a381"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, "constant_pairs": {"count": 694, "sha256": "ae11bdde04d49f9dfe83170f0aa362e6bf18d3fdc381c1bdf5bbc256caa400ab"}, - "unpaired_files": {"count": 373, "sha256": "2b0c9bc717b301037f735ea7cc32f7a7cd9b4a7887d46550b9e727ffd66d3639"}, - "unpaired_functions": {"count": 4100, "sha256": "cc10b9887488dddae0c270071d3fde36a0c6c764df318e18a27e569e181051a9"}, + "unpaired_files": {"count": 371, "sha256": "d9e682eadcdd5b1279a69a45799fd68b4589f98c83cfb7e15d3016ad890a98c0"}, + "unpaired_functions": {"count": 4097, "sha256": "e54745434cdd990e42445672ec7ae137bfe1111def4f701948fa1ae7398e5871"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 597, "sha256": "578c607cc05b1cd1f867c206c9fab70237ac40707cd49d4e0c5cc66b400184c7"} }