|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The AI-model diagnostics panel composes its own summary line (objectui#2886). |
| 5 | + * |
| 6 | + * `service-ai` builds `report.summary` as a hard-coded English template literal |
| 7 | + * and its route handler takes no request argument, so it cannot honour |
| 8 | + * `Accept-Language` even though the client has been sending it since |
| 9 | + * objectui#1319. Every ingredient of that sentence is already in the structured |
| 10 | + * fields, so the panel assembles a localized equivalent and ignores `summary`. |
| 11 | + * |
| 12 | + * These tests pin the two things that regressed before: the English string must |
| 13 | + * not reach the DOM, and `routing` — which the server always sent but the client |
| 14 | + * never declared — must be surfaced. |
| 15 | + */ |
| 16 | +import '@testing-library/jest-dom/vitest'; |
| 17 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 18 | +import { render, screen, waitFor, cleanup } from '@testing-library/react'; |
| 19 | +import { I18nProvider, createI18n } from '@object-ui/i18n'; |
| 20 | + |
| 21 | +vi.mock('@object-ui/auth', () => ({ |
| 22 | + createAuthenticatedFetch: () => vi.fn().mockImplementation(() => Promise.resolve(mockResponse())), |
| 23 | +})); |
| 24 | + |
| 25 | +let payload: Record<string, unknown>; |
| 26 | +const mockResponse = () => |
| 27 | + ({ ok: true, status: 200, json: () => Promise.resolve(payload) }) as unknown as Response; |
| 28 | + |
| 29 | +import { CloudAiModelStatus } from './CloudAiModelStatus'; |
| 30 | + |
| 31 | +/** The English sentence the server sends and this panel must NOT render. */ |
| 32 | +const SERVER_SUMMARY = |
| 33 | + 'build/ask uses gpt-5 (pinned by OS_AI_MODEL); structured uses gpt-5-mini. ' + |
| 34 | + 'Routing policy: free plans → gpt-5-mini, paid plans → gpt-5.'; |
| 35 | + |
| 36 | +function baseReport(extra: Record<string, unknown> = {}) { |
| 37 | + return { |
| 38 | + conversational: { model: 'gpt-5', source: 'env:OS_AI_MODEL' }, |
| 39 | + structured: { model: 'gpt-5-mini', pinned: true, source: 'env:OS_AI_MODEL_STRUCTURED' }, |
| 40 | + reasoningEffort: { effective: 'medium', source: 'code-default' }, |
| 41 | + adapter: 'vercel', |
| 42 | + overrides: { OS_AI_MODEL: 'gpt-5' }, |
| 43 | + summary: SERVER_SUMMARY, |
| 44 | + ...extra, |
| 45 | + }; |
| 46 | +} |
| 47 | + |
| 48 | +function renderPanel(lang = 'en') { |
| 49 | + // `I18nProvider` takes a config/instance, not a `language` prop — passing one |
| 50 | + // is silently ignored at runtime, so pin the language on the instance. |
| 51 | + const instance = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); |
| 52 | + return render( |
| 53 | + <I18nProvider instance={instance}> |
| 54 | + <CloudAiModelStatus /> |
| 55 | + </I18nProvider>, |
| 56 | + ); |
| 57 | +} |
| 58 | + |
| 59 | +beforeEach(() => { |
| 60 | + payload = baseReport(); |
| 61 | +}); |
| 62 | +afterEach(cleanup); |
| 63 | + |
| 64 | +describe('CloudAiModelStatus — summary is composed client-side', () => { |
| 65 | + it('renders a localized summary and never the server’s English string', async () => { |
| 66 | + renderPanel('en'); |
| 67 | + const line = await screen.findByTestId('ai-model-summary'); |
| 68 | + |
| 69 | + expect(line).toHaveTextContent('gpt-5'); |
| 70 | + expect(line).toHaveTextContent('gpt-5-mini'); |
| 71 | + // The regression: `<p>{report.summary}</p>` put this verbatim on screen for |
| 72 | + // every locale. |
| 73 | + expect(line.textContent).not.toBe(SERVER_SUMMARY); |
| 74 | + expect(document.body.textContent).not.toContain('build/ask uses'); |
| 75 | + }); |
| 76 | + |
| 77 | + it('surfaces the routing policy the client type used to drop', async () => { |
| 78 | + payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } }); |
| 79 | + renderPanel('en'); |
| 80 | + const line = await screen.findByTestId('ai-model-summary'); |
| 81 | + // Routing only ever appeared inside the English summary, so non-English |
| 82 | + // admins could not see it at all. |
| 83 | + expect(line).toHaveTextContent(/Routing policy/i); |
| 84 | + expect(line).toHaveTextContent('gpt-5-mini'); |
| 85 | + }); |
| 86 | + |
| 87 | + it('omits the routing clause when the host does not inject one', async () => { |
| 88 | + renderPanel('en'); |
| 89 | + const line = await screen.findByTestId('ai-model-summary'); |
| 90 | + expect(line).not.toHaveTextContent(/Routing policy/i); |
| 91 | + }); |
| 92 | + |
| 93 | + it('translates the summary — a zh render shares no sentence text with en', async () => { |
| 94 | + payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } }); |
| 95 | + const { unmount } = renderPanel('en'); |
| 96 | + const en = (await screen.findByTestId('ai-model-summary')).textContent ?? ''; |
| 97 | + unmount(); |
| 98 | + |
| 99 | + renderPanel('zh'); |
| 100 | + const zh = (await screen.findByTestId('ai-model-summary')).textContent ?? ''; |
| 101 | + |
| 102 | + expect(zh).not.toBe(en); |
| 103 | + expect(zh).toMatch(/[一-鿿]/); |
| 104 | + // Model ids are identifiers, not prose — they must survive translation. |
| 105 | + expect(zh).toContain('gpt-5'); |
| 106 | + }); |
| 107 | + |
| 108 | + it('falls back to a translated placeholder when the adapter reports no model', async () => { |
| 109 | + payload = baseReport({ |
| 110 | + conversational: { model: undefined, source: 'unknown' }, |
| 111 | + structured: { model: undefined, pinned: false, source: 'inherits-conversational' }, |
| 112 | + }); |
| 113 | + renderPanel('en'); |
| 114 | + const line = await screen.findByTestId('ai-model-summary'); |
| 115 | + // `attributeSource` emits the bare token 'unknown'; it used to render raw. |
| 116 | + expect(line.textContent).not.toMatch(/\bunknown\s*\)/); |
| 117 | + expect(line).toHaveTextContent(/not reported by the adapter/i); |
| 118 | + }); |
| 119 | +}); |
0 commit comments