diff --git a/.changeset/10242-ru-ar-count-labels.md b/.changeset/10242-ru-ar-count-labels.md new file mode 100644 index 0000000000..858891200d --- /dev/null +++ b/.changeset/10242-ru-ar-count-labels.md @@ -0,0 +1,30 @@ +--- +'@object-ui/i18n': patch +--- + +Russian now reads correctly at every count on the tab-count badge's accessible +name and the activity-feed reaction chip, and Arabic on the badge and the comment +thread's count and reaction tooltip (objectui#10242). + +Each of these surfaces picks its `…One` key at exactly one and the plain key at +every other count. Two slots cover English. They do not cover Russian, which needs +three noun forms for whole numbers, or Arabic, which needs more. Where the plain key +held a single noun form, it was wrong at some of the counts it serves. A Russian tab +badge read `2 элементов` at two (the language needs `2 элемента`) and +`21 элементов` at twenty-one (it needs `21 элемент`), and a Russian reaction chip +read `2 реакций` at two. The Arabic badge, comment count and reaction tooltip used +the plural form for three to ten, which is wrong at two and from eleven up. + +The count-not-one half of these five values is now a count label that reads +correctly at any number: + +- `ru` `common.itemCount`: `Элементов: N` +- `ru` `detail.reactionCount`: the emoji, then `Реакций: N` +- `ar` `common.itemCount`: `عدد العناصر: N` +- `ar` `collaboration.commentCount`: `عدد التعليقات: N` +- `ar` `collaboration.reactionCount`: `عدد التفاعلات: N` + +That is the device objectui#10024 used for `search.resultsCountPlural`, and the one +Russian's `collaboration.commentCount` and `collaboration.reactionCount` already +use. The singular halves, the key set, the call sites and the other eight packs are +unchanged. diff --git a/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx new file mode 100644 index 0000000000..66fa65c259 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx @@ -0,0 +1,264 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10242 — the sibling keys of objectui#10024. Three surfaces choose + * their key on `count === 1`: the `page:tabs` count badge's accessible name + * (`common.itemCount`/`itemCountOne`), the activity-feed reaction chip + * (`detail.reactionCount`/`reactionCountOne`) and the comment thread's header + * and reaction tooltip (`collaboration.commentCount`/`reactionCount` and their + * `…One` halves). Two slots fit `en`. They do not fit `ru` (one: 1, 21, 31…; + * few: 2-4, 22-24…; many: 0, 5-20…) or `ar` (zero, one, two, few 3-10, + * many 11-99, other 100-102…). + * + * What shipped before this card: the count-not-one half held one noun form. In + * `ru` that was the `many` form, so a badge read `2 элементов` at two and + * `21 элементов` at twenty-one, and a chip read `👍 2 реакций`. In `ar` it was + * the 3-10 plural, so the badge, the header and the tooltip were wrong at 2 and + * from 11 up. + * + * ## The repair: the objectui#10024 device, key by key + * + * The count-not-one half becomes a count label — a label, a colon, then the + * number — so no noun has to agree with the number. The `ru` values match the + * `ru` controls that already read right, `collaboration.commentCount` + * (`Комментариев: {{count}}`) and `collaboration.reactionCount` + * (`Реакций: {{count}}`). The `ar` values use the `عدد …: {{count}}` shape of + * `search.resultsCountPlural` and `calendar.a11y.dayCell`. The singular halves, + * the key set and the call sites are unchanged. ⛔ Not an i18next `_few` / + * `_two` family: `all-locales-key-parity.test.ts` holds every pack to `en`'s + * key set, so a `ru` `_few` is a key `en` lacks. + * + * ## Why this file sits beside the objectui#10024 pin + * + * It is the same defect on the neighbouring keys, pinned in the same shape + * (`SearchResultsPage.resultsCountPlural-10024.test.tsx`). `@object-ui/app-shell` + * is also the one package that depends on all three consumers — + * `@object-ui/components`, `@object-ui/plugin-detail` and + * `@object-ui/collaboration` — so one file can render each of them for real. + * + * ## What this file measures + * + * The strings a user reads, from a real render inside a real i18next instance + * booted in the session's language. `t` is not mocked. The reaction chip + * passes `count` as a number, so i18next's own plural lookup (`_few` before the + * bare key) is in the path. `en` and the `ru` comment thread are the controls: + * this card does not change them. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import React from 'react'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { SchemaRenderer } from '@object-ui/react'; +// Registers `page:tabs` (and every other renderer) at module scope, never in a +// hook — see object-ui/no-dynamic-import-in-test-hook. +import '@object-ui/components'; +import { ReactionPicker } from '@object-ui/plugin-detail'; +import { CommentThread, type Comment } from '@object-ui/collaboration'; + +type Lang = 'en' | 'ru' | 'ar'; + +afterEach(() => cleanup()); + +function boot(lang: Lang) { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + // The instance really booted in the language under test; otherwise every + // string below would be `en` read through `fallbackLng`. + expect(i18n.language).toBe(lang); + return i18n; +} + +/** The `page:tabs` count badge's accessible name at `count`. */ +function tabBadge(lang: Lang, count: number): string { + const text = (content: string) => [{ type: 'element:text', properties: { content } }]; + const { container } = render( + + , + ); + const badges = container.querySelectorAll('[role="tab"] span[aria-label]'); + expect(badges, `${lang} at ${count}: tab badge not found exactly once`).toHaveLength(1); + expect(badges[0].textContent).toBe(String(count)); + const name = badges[0].getAttribute('aria-label') ?? ''; + cleanup(); + return name; +} + +/** The activity-feed reaction chip's accessible name at `count`. */ +function reactionChip(lang: Lang, count: number): string { + const { container } = render( + + + , + ); + // No `onToggleReaction`, so no add-reaction button: the chip is the only one. + const chips = container.querySelectorAll('button[aria-label]'); + expect(chips, `${lang} at ${count}: reaction chip not found exactly once`).toHaveLength(1); + const name = chips[0].getAttribute('aria-label') ?? ''; + cleanup(); + return name; +} + +const users = (n: number) => Array.from({ length: n }, (_, i) => `u${i}`); + +/** + * The comment thread at `count`: its header line with `count` comments, and + * the tooltip of a reaction that `count` users gave. + */ +function commentThread(lang: Lang, count: number): { header: string; tooltip: string } { + const comments: Comment[] = Array.from({ length: count }, (_, i) => ({ + id: `c${i}`, + author: { id: 'u0', name: 'Author' }, + content: 'Looks good.', + mentions: [], + createdAt: new Date().toISOString(), + reactions: i === 0 ? { '👍': users(count) } : undefined, + })); + const { container } = render( + + + , + ); + // The header's count sits in a leaf span that holds nothing else (the thread + // is unresolved, so no suffix follows it). Every other leaf span is a name, + // a timestamp or a comment body, and none of those carries a digit here. + const headers = Array.from(container.querySelectorAll('span')).filter( + (span) => span.children.length === 0 && (span.textContent ?? '').includes(String(count)), + ); + expect(headers, `${lang} at ${count}: header not found exactly once`).toHaveLength(1); + const chips = Array.from(container.querySelectorAll('button[title]')).filter( + (button) => button.textContent === `👍 ${count}`, + ); + expect(chips, `${lang} at ${count}: reaction chip not found exactly once`).toHaveLength(1); + const result = { + header: headers[0].textContent ?? '', + tooltip: chips[0].getAttribute('title') ?? '', + }; + cleanup(); + return result; +} + +/** One row per count: badge name, thread header, thread tooltip. */ +type Row = [count: number, badge: string, header: string, tooltip: string]; + +/** + * Every string this file expects. `1` is the untouched singular half. The + * other counts are the triage's representative numbers: `ru` 2 (few), 5 (many) + * and 21 (one); `ar` 2 (two), 3 (few) and 11 (many). + */ +const EXPECTED: Record = { + en: [ + [1, '1 item', '1 comment', '1 reaction'], + [2, '2 items', '2 comments', '2 reactions'], + ], + ru: [ + [1, '1 элемент', '1 комментарий', '1 реакция'], + [2, 'Элементов: 2', 'Комментариев: 2', 'Реакций: 2'], + [5, 'Элементов: 5', 'Комментариев: 5', 'Реакций: 5'], + [21, 'Элементов: 21', 'Комментариев: 21', 'Реакций: 21'], + ], + ar: [ + [1, '1 عنصر', '1 تعليق', '1 تفاعل'], + [2, 'عدد العناصر: 2', 'عدد التعليقات: 2', 'عدد التفاعلات: 2'], + [3, 'عدد العناصر: 3', 'عدد التعليقات: 3', 'عدد التفاعلات: 3'], + [11, 'عدد العناصر: 11', 'عدد التعليقات: 11', 'عدد التفاعلات: 11'], + ], +}; + +/** + * The reaction chip, `en` and `ru` only: the card's key list names `ru` + * `detail.reactionCount`, not the `ar` one. + */ +const CHIP: Record<'en' | 'ru', Array<[number, string]>> = { + en: [ + [1, '👍 1 reaction'], + [2, '👍 2 reactions'], + ], + ru: [ + [1, '👍 1 реакция'], + [2, '👍 Реакций: 2'], + [5, '👍 Реакций: 5'], + [21, '👍 Реакций: 21'], + ], +}; + +describe('count labels read correctly at every CLDR category in ru and ar (objectui#10242)', () => { + it('the representative counts reach the categories the count-not-one half serves', () => { + const ru = new Intl.PluralRules('ru'); + const ar = new Intl.PluralRules('ar'); + expect([2, 5, 21].map((n) => ru.select(n))).toEqual(['few', 'many', 'one']); + expect([2, 3, 11].map((n) => ar.select(n))).toEqual(['two', 'few', 'many']); + }); + + describe.each(['en', 'ru', 'ar'] as const)('%s', (lang) => { + it.each(EXPECTED[lang])('page:tabs badge at %i', (count, badge) => { + expect(tabBadge(lang, count)).toBe(badge); + }); + + it.each(EXPECTED[lang])('comment thread at %i', (count, _badge, header, tooltip) => { + expect(commentThread(lang, count)).toEqual({ header, tooltip }); + }); + }); + + describe.each(['en', 'ru'] as const)('%s', (lang) => { + it.each(CHIP[lang])('reaction chip at %i', (count, chip) => { + expect(reactionChip(lang, count)).toBe(chip); + }); + }); + + it('ru no longer binds the many-form noun to a few or one count', () => { + // The shipped defect, named as the strings a Russian user read. + expect(tabBadge('ru', 2)).not.toBe('2 элементов'); + expect(tabBadge('ru', 21)).not.toBe('21 элементов'); + expect(reactionChip('ru', 2)).not.toBe('👍 2 реакций'); + expect(reactionChip('ru', 21)).not.toBe('👍 21 реакций'); + }); + + it('ar no longer binds the 3-10 plural to a dual or an 11+ count', () => { + // The shipped defect, named as the strings an Arabic user read. + expect(tabBadge('ar', 2)).not.toBe('2 عناصر'); + expect(tabBadge('ar', 11)).not.toBe('11 عناصر'); + expect(commentThread('ar', 2)).not.toEqual({ header: '2 تعليقات', tooltip: '2 تفاعلات' }); + expect(commentThread('ar', 11).header).not.toBe('11 تعليقات'); + expect(commentThread('ar', 11).tooltip).not.toBe('11 تفاعلات'); + }); + + it('the count-not-one half is count-invariant: it ends in the number, after a colon', () => { + // The mechanism of the repair, stated on the pack values, so a later + // "natural-sounding" rewrite back to a number-then-noun form fails here + // with the reason attached: two slots cannot give that noun the right form. + const at = (lang: 'ru' | 'ar', dotted: string) => + dotted + .split('.') + .reduce((node, part) => (node as Record)?.[part], builtInLocales[lang]); + const KEYS: Record<'ru' | 'ar', string[]> = { + ru: ['common.itemCount', 'detail.reactionCount'], + ar: ['common.itemCount', 'collaboration.commentCount', 'collaboration.reactionCount'], + }; + for (const lang of ['ru', 'ar'] as const) { + for (const key of KEYS[lang]) { + const value = at(lang, key); + expect(value, `${lang} ${key}`).toMatch(/: \{\{count\}\}$/); + // …and the two halves are two different sentences. + expect(value, `${lang} ${key} equals its One half`).not.toBe(at(lang, `${key}One`)); + } + } + }); +}); diff --git a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx index b6a6965923..c466a113cc 100644 --- a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx @@ -642,10 +642,16 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', ru: 'элементов', ar: 'عناصر', }; + // `ru` and `ar` state that noun inside a count label since objectui#10242, + // because their `common.itemCount` half serves every count but 1. + const ITEM_COUNT_LABEL: Partial> = { + ru: 'Элементов: {{count}}', + ar: 'عدد العناصر: {{count}}', + }; for (const [lang, unit] of Object.entries(UNIT) as [LocaleCode, string][]) { - // the premise, unchanged: that unit really is what common.itemCount uses + // the premise: that unit really is what common.itemCount uses expect(at(builtInLocales[lang], 'common.itemCount'), `${lang} common.itemCount`).toBe( - `{{count}} ${unit}`, + ITEM_COUNT_LABEL[lang] ?? `{{count}} ${unit}`, ); expect( (at(builtInLocales[lang], 'preview.history.items') as string).toLowerCase(), diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 52431b07b1..64f927a97b 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -121,7 +121,11 @@ const ar = { closeChat: "إغلاق المحادثة", closePanel: "إغلاق اللوحة", resizeDrawer: "تغيير عرض اللوحة", - itemCount: "{{count}} عناصر", + // objectui#10242 — the tab-count badge switches keys only at exactly 1, so + // this half serves two (2), few (3-10), many (11-99) and other (100+), + // which need different noun forms. A count label («عدد …: {{count}}», as + // `search.resultsCountPlural` does) reads correctly at every one of them. + itemCount: "عدد العناصر: {{count}}", itemCountOne: "{{count}} عنصر", toggleSidebar: "تبديل الشريط الجانبي", package: "الحزمة", @@ -3639,7 +3643,10 @@ const ar = { removeSort: "إزالة الترتيب", }, collaboration: { - commentCount: "{{count}} تعليقات", + // objectui#10242 — `commentCount` and `reactionCount` below serve every + // count but 1 (zero, two, few, many, other), so each is a count label + // («عدد …: {{count}}») rather than one noun form that fits only 3-10. + commentCount: "عدد التعليقات: {{count}}", commentCountOne: "{{count}} تعليق", resolvedSuffix: " · تم الحل", sortComments: "ترتيب التعليقات", @@ -3652,7 +3659,7 @@ const ar = { hoursAgo: "قبل {{count}} ساعة", daysAgo: "قبل {{count}} يوم", edited: "(تم التعديل)", - reactionCount: "{{count}} تفاعلات", + reactionCount: "عدد التفاعلات: {{count}}", reactionCountOne: "{{count}} تفاعل", addThumbsUp: "إضافة إعجاب", reply: "رد", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 02e4749628..e7d8d5ee50 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -123,7 +123,11 @@ const ru = { closeChat: "Закрыть чат", closePanel: "Закрыть панель", resizeDrawer: "Изменить ширину панели", - itemCount: "{{count}} элементов", + // objectui#10242 — the tab-count badge switches keys only at exactly 1, so + // this half serves one (21, 31…), few (2-4, 22-24…) AND many (5-20…). No + // single `{{count}} ` form agrees with all three; a count label does, + // the same device as `detail.repeaterItemCount`. + itemCount: "Элементов: {{count}}", itemCountOne: "{{count}} элемент", toggleSidebar: "Переключить боковую панель", package: "Пакет", @@ -936,7 +940,10 @@ const ru = { addReaction: "Добавить реакцию", pageHeaderActions: "Действия в заголовке страницы", emojiPicker: "Выбор эмодзи", - reactionCount: "{{emoji}} {{count}} реакций", + // objectui#10242 — the chip picks this half at every count but 1, which + // spans one, few and many; a count label reads right at all three, the same + // device as `collaboration.reactionCount`. + reactionCount: "{{emoji}} Реакций: {{count}}", reactionCountOne: "{{emoji}} {{count}} реакция", recordDetail: "Сведения о записи", recordDetailWithLabel: "Сведения: {{label}}",