diff --git a/apps/web/__tests__/i18n/solver-step-rules.test.ts b/apps/web/__tests__/i18n/solver-step-rules.test.ts new file mode 100644 index 00000000..ab10e0c8 --- /dev/null +++ b/apps/web/__tests__/i18n/solver-step-rules.test.ts @@ -0,0 +1,142 @@ +/** + * i18n coverage for the StepTrace-driven solver panel. + * + * Guards against future drift between the math-engine's trace rule ids and + * the web layer's translations: every displayable ruleId, every limitTab + * key, and every stepCategories key must exist with a non-empty value in + * ALL 8 locale files. A missing key renders literally as + * `solver.stepRules..title` in the UI, or (for stepRules) silently + * falls back to the engine's English text — either way, catch it here + * instead of in production. + */ +import { DISPLAY_RULES } from '@nextcalc/math-engine'; +import { describe, expect, it } from 'vitest'; +import de from '@/messages/de.json'; +import en from '@/messages/en.json'; +import es from '@/messages/es.json'; +import fr from '@/messages/fr.json'; +import ja from '@/messages/ja.json'; +import ru from '@/messages/ru.json'; +import uk from '@/messages/uk.json'; +import zh from '@/messages/zh.json'; + +const LOCALES = { en, de, es, fr, ja, ru, uk, zh } as const; +type LocaleCode = keyof typeof LOCALES; +const LOCALE_CODES = Object.keys(LOCALES) as LocaleCode[]; + +/** + * Snapshot of `solver.stepCategories.*` keys, camelCased per + * `CategoryBadge`'s `labelKey` construction in + * apps/web/components/calculator/solver-panel.tsx (source of truth: + * the `CATEGORY_STYLES` map in that file). + */ +const STEP_CATEGORY_KEYS = [ + 'identification', + 'simplification', + 'differentiation', + 'integration', + 'rearrangement', + 'isolation', + 'factorization', + 'formula', + 'substitution', + 'expansion', + 'identity', + 'finalAnswer', + 'evaluation', + 'limit', +] as const; + +/** `solver.limitTab.*` keys referenced by t() calls in solver-panel.tsx. */ +const LIMIT_TAB_KEYS = [ + 'label', + 'shortLabel', + 'approach', + 'pointPlaceholder', + 'direction', + 'directionBoth', + 'directionLeft', + 'directionRight', + 'invalidPoint', +] as const; + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Walk a dot-separated path through a nested object, mirroring next-intl's + * own `resolvePath` (use-intl splits every translation key on "." and + * descends one nested object per segment — see + * `useTranslations('solver')(\`stepRules.${ruleId}.title\`)` in + * solver-panel.tsx). A ruleId like "equation.classify.linear" therefore + * requires `stepRules.equation.classify.linear`, NOT a flat key containing + * literal dots. + */ +function resolveDotPath(root: unknown, dotted: string): unknown { + return dotted.split('.').reduce((node, segment) => { + if (node && typeof node === 'object' && segment in node) { + return (node as Record)[segment]; + } + return undefined; + }, root); +} + +describe('solver i18n coverage — stepRules', () => { + // DISPLAY_RULES is the exact whitelist curateTrace() keeps (RULE_IDS minus + // internal bookkeeping rules) — every id in it is user-visible and must + // resolve to a translation in every locale. + const ruleIds = [...DISPLAY_RULES].sort(); + + it('DISPLAY_RULES is non-empty (sanity check against a stale import)', () => { + expect(ruleIds.length).toBeGreaterThan(40); + }); + + it.each(ruleIds)('has title + detail for every locale: %s', (ruleId) => { + for (const locale of LOCALE_CODES) { + const messages = LOCALES[locale]; + const entry = resolveDotPath(messages.solver, `stepRules.${ruleId}`) as + | { title?: unknown; detail?: unknown } + | undefined; + + expect(entry, `${locale}: solver.stepRules.${ruleId} is missing`).toBeDefined(); + expect( + nonEmptyString(entry?.title), + `${locale}: solver.stepRules.${ruleId}.title is missing or empty`, + ).toBe(true); + expect( + nonEmptyString(entry?.detail), + `${locale}: solver.stepRules.${ruleId}.detail is missing or empty`, + ).toBe(true); + } + }); +}); + +describe('solver i18n coverage — limitTab', () => { + it.each(LIMIT_TAB_KEYS)('has a translation for every locale: limitTab.%s', (key) => { + for (const locale of LOCALE_CODES) { + const messages = LOCALES[locale]; + const limitTab = (messages.solver as unknown as { limitTab?: Record }) + .limitTab; + expect( + nonEmptyString(limitTab?.[key]), + `${locale}: solver.limitTab.${key} is missing or empty`, + ).toBe(true); + } + }); +}); + +describe('solver i18n coverage — stepCategories', () => { + it.each(STEP_CATEGORY_KEYS)('has a translation for every locale: stepCategories.%s', (key) => { + for (const locale of LOCALE_CODES) { + const messages = LOCALES[locale]; + const stepCategories = ( + messages.solver as unknown as { stepCategories?: Record } + ).stepCategories; + expect( + nonEmptyString(stepCategories?.[key]), + `${locale}: solver.stepCategories.${key} is missing or empty`, + ).toBe(true); + } + }); +}); diff --git a/apps/web/components/calculator/solver-panel.test.tsx b/apps/web/components/calculator/solver-panel.test.tsx new file mode 100644 index 00000000..680b2d17 --- /dev/null +++ b/apps/web/components/calculator/solver-panel.test.tsx @@ -0,0 +1,256 @@ +/** + * Component tests for SolverPanel — focused on the Limit tab (roadmap #4, + * StepTrace) added on top of the pre-existing equation/simplify/derivative/ + * integral tabs: tab wiring, approach-point parsing, ruleId localization + * (translation vs. engine-English fallback), and category-badge fallback. + * + * The global `next-intl` mock in vitest.setup.ts stubs `t()` as an identity + * function with no `.has()` method, which can't exercise `localizeStep`'s + * translate-vs-fallback branch. This file overrides that mock with a real + * lookup against the actual `solver` namespace in messages/en.json, so the + * translated strings asserted against here are the genuine shipped copy + * (not test-only fixtures) and `t.has()` behaves like next-intl's own + * `resolvePath` (dot-segments walk nested objects). + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SolverPanel } from './solver-panel'; + +vi.mock('next-intl', async (importOriginal) => { + const actual = await importOriginal(); + const messages = (await import('../../messages/en.json')).default as Record; + + function resolveDotPath(root: unknown, dotted: string): unknown { + return dotted.split('.').reduce((node, segment) => { + if (node && typeof node === 'object' && segment in (node as object)) { + return (node as Record)[segment]; + } + return undefined; + }, root); + } + + function interpolate(template: string, values?: Record): string { + if (!values) return template; + return template.replace(/\{(\w+)\}/g, (match, name) => + name in values ? String(values[name]) : match, + ); + } + + function makeTranslator(namespace: string) { + const root = resolveDotPath(messages, namespace) ?? {}; + const t = (key: string, values?: Record) => { + const node = resolveDotPath(root, key); + return typeof node === 'string' ? interpolate(node, values) : key; + }; + t.has = (key: string) => typeof resolveDotPath(root, key) === 'string'; + return t; + } + + return { + ...actual, + useTranslations: (namespace: string) => makeTranslator(namespace), + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (value: Date) => value.toISOString(), + number: (value: number) => String(value), + relativeTime: () => 'just now', + list: (value: Iterable) => [...value].join(', '), + }), + useMessages: () => messages, + useTimeZone: () => 'UTC', + useNow: () => new Date(), + }; +}); + +const mockSolveWithSteps = vi.fn(); +const mockLimitWithSteps = vi.fn(); + +vi.mock('@nextcalc/math-engine/symbolic', () => ({ + ProblemType: { + Simplification: 'Simplification', + Equation: 'Equation', + Derivative: 'Derivative', + Integral: 'Integral', + Limit: 'Limit', + Expansion: 'Expansion', + Factorization: 'Factorization', + }, + solveWithSteps: (...args: unknown[]) => mockSolveWithSteps(...args), + limitWithSteps: (...args: unknown[]) => mockLimitWithSteps(...args), + integrate: vi.fn(), + astToString: vi.fn(() => ''), +})); + +describe('SolverPanel — Limit tab (StepTrace)', () => { + beforeEach(() => { + mockSolveWithSteps.mockReset(); + mockLimitWithSteps.mockReset(); + }); + + it('selecting the Limit tab reveals approach-point and direction controls', async () => { + const user = userEvent.setup(); + render(); + + const limitTab = screen.getByRole('tab', { name: /limit/i }); + await user.click(limitTab); + + expect(screen.getByLabelText('Approach point')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('e.g. 0, inf, -inf')).toBeInTheDocument(); + // Two "Direction" accessible names exist (the