Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/10242-ru-ar-count-labels.md
Original file line number Diff line number Diff line change
@@ -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.
264 changes: 264 additions & 0 deletions packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider instance={boot(lang)}>
<SchemaRenderer
schema={{
type: 'page:tabs',
id: 'tabs',
// Two tabs: the strip, and with it the badge, is hidden at one.
items: [
{ label: 'Related', value: 'related', count, children: text('A') },
{ label: 'Details', value: 'details', children: text('B') },
],
}}
/>
</I18nProvider>,
);
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(
<I18nProvider instance={boot(lang)}>
<ReactionPicker reactions={[{ emoji: '👍', count, reacted: false }]} />
</I18nProvider>,
);
// 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(
<I18nProvider instance={boot(lang)}>
<CommentThread threadId="t" comments={comments} currentUser={{ id: 'u0', name: 'Author' }} />
</I18nProvider>,
);
// 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<Lang, Row[]> = {
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<unknown>((node, part) => (node as Record<string, unknown>)?.[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`));
}
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<LocaleCode, string>> = {
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(),
Expand Down
13 changes: 10 additions & 3 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "الحزمة",
Expand Down Expand Up @@ -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: "ترتيب التعليقات",
Expand All @@ -3652,7 +3659,7 @@ const ar = {
hoursAgo: "قبل {{count}} ساعة",
daysAgo: "قبل {{count}} يوم",
edited: "(تم التعديل)",
reactionCount: "{{count}} تفاعلات",
reactionCount: "عدد التفاعلات: {{count}}",
reactionCountOne: "{{count}} تفاعل",
addThumbsUp: "إضافة إعجاب",
reply: "رد",
Expand Down
11 changes: 9 additions & 2 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} <noun>` form agrees with all three; a count label does,
// the same device as `detail.repeaterItemCount`.
itemCount: "Элементов: {{count}}",
itemCountOne: "{{count}} элемент",
toggleSidebar: "Переключить боковую панель",
package: "Пакет",
Expand Down Expand Up @@ -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}}",
Expand Down
Loading