import type { Page } from 'playwright' import { siteLanguagesBody } from '../src/test/storyLists' import { listApi } from './articleListStates' import { articleBody, fails, hangs, ok, pending, settle, site, state, type Setup, } from './editorLocalizationStates' import type { ApiMock, DiffException, VisualState } from './states' const FRENCH_TITLE_ROOF = "Liste de contrôle pour l'inspection de la toiture" const FRENCH_TITLE_LONG = `${FRENCH_TITLE_ROOF} avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes` const FRENCH_TITLE_PRICING = 'Comment nous chiffrons une rénovation de salle de bain' const ROOF_TITLE = 'Roof inspection checklist' const AUTHOR_NAMES = [ 'Marta Alves', 'Camille Roche', 'Luis Ferrer', 'Ana Pereira', 'Jonas Weber', 'Inès Duarte', 'Paul Martin', 'Sofia Costa', 'Omar Haddad', ] const COVER_IMAGE_ID = 1 const IMAGE_COUNT = 8 function boardSite(id: number, key: string, name: string) { return { ...site, id, key, name, site_name: name, is_default: id === 1, base_url: `https://${key}.example.com`, } } const boardSites = [ boardSite(1, 'journal', 'Journal'), boardSite(2, 'atelier', 'Atelier'), boardSite(3, 'notes', 'Notes'), boardSite(4, 'archive', 'Archive'), ] function languagesOf(tags: string[]) { const base = siteLanguagesBody('journal') return { ...base, revision: `${tags.join('-')}-1`, languages: base.languages.filter((language) => tags.includes(language.lang)), } } const modalApi = (overrides: Record = {}) => listApi({ 'GET /v1/site/': ok(boardSites), 'GET /v1/site/atelier/languages': ok(languagesOf(['en', 'fr', 'de'])), 'GET /v1/site/notes/languages': ok(languagesOf(['en'])), 'GET /v1/site/archive/languages': ok(languagesOf(['en'])), ...overrides, }) const roofStory = { id: 105, story_id: 5, title: ROOF_TITLE, body: '', images: [], is_online: true, lang: 'en', site_id: 1, created_at: '2026-03-12T09:14:00Z', updated_at: '2026-10-14T09:00:00Z', story_updated_at: '2026-10-14T09:00:00Z', story_authors: [], capabilities: ['read', 'edit', 'publish', 'delete', 'create_translation'], missing_langs: ['fr', 'ar'], coverage: [ { id: 105, lang: 'en', title: ROOF_TITLE, is_online: true, language_enabled: true, capabilities: ['read'], }, { id: 106, lang: 'de', title: 'Checkliste für die Dachprüfung', is_online: false, language_enabled: true, capabilities: ['read'], }, ], } const missingPage: ApiMock = { status: 200, bodyFor: () => ({ items: [roofStory], total: 1, page: 1, size: 20, counts: { all: 1, online: 1, draft: 0, missing: 1 }, }), } function listWithMissing(): Record { const base = modalApi() const list = base['GET /v1/article/']! return { 'GET /v1/article/': { status: 200, bodyFor: (url, call) => url.searchParams.get('tab') === 'missing' ? missingPage.bodyFor!(url, call) : list.bodyFor!(url, call), }, } } async function openModal(page: Page) { await page.getByRole('button', { name: 'New article', exact: true }).first().click() await page.getByRole('dialog').getByText('Give the article a title').waitFor() await settle(page) } async function chooseOption(page: Page, field: string, option: string) { await page.getByRole('combobox', { name: field, exact: true }).click() await page.getByRole('option', { name: option, exact: true }).click() await settle(page) } async function modalTitle(page: Page, value: string) { const input = page.getByRole('dialog').getByRole('textbox').first() await input.fill(value) } const modal = ( steps: (page: Page) => Promise, overrides: Record = {}, exception?: DiffException, ): VisualState => ({ exception, path: '/article?site=journal&size=5', timezone: 'UTC', api: modalApi(overrides), steps: async (page) => { await openModal(page) await steps(page) await page.mouse.move(0, 0) }, }) async function startTranslation(page: Page) { await page.getByRole('radio', { name: /Translation of an existing article/ }).click() await settle(page) await chooseOption(page, 'Language', 'French') await page.getByLabel('Article', { exact: true }).click() await page.getByRole('option', { name: ROOF_TITLE }).click() await settle(page) await page.getByRole('dialog').getByRole('textbox').first().fill(FRENCH_TITLE_ROOF) await settle(page) } const roofEditor: Setup = { current: 'en', siblings: ['en', 'de'], title: ROOF_TITLE, online: { en: true, de: false }, search: '?dialog=add-translation&lang=fr', } const pricingEditor: Setup = { current: 'en', siblings: ['en', 'de', 'ar'], online: { en: true, de: true, ar: false }, search: '?dialog=add-translation&lang=fr', } async function dialogTitle(page: Page, value: string) { const input = page.getByRole('dialog').getByRole('textbox').first() await input.waitFor() await input.fill(value) } async function submitTranslation(page: Page) { await page.getByRole('dialog').getByRole('button', { name: 'Create French draft' }).click() await settle(page) } const withoutImages = (setup: Setup): ApiMock => ({ status: 200, bodyFor: () => ({ ...articleBody(setup, 'en'), images: [] }), }) const addTranslation = ( setup: Setup, title: string, after?: (page: Page) => Promise, overrides: Record = {}, exception?: DiffException, ) => state( setup, async (page) => { await dialogTitle(page, title) await settle(page) await after?.(page) await page.mouse.move(0, 0) }, { 'GET /v1/article/10': withoutImages(setup), ...overrides }, exception, ) const rewindDialog = async (page: Page) => { await page.keyboard.press('Home') await page.getByRole('dialog').evaluate((dialog) => { dialog.querySelectorAll('*').forEach((node) => { node.scrollTop = 0 }) }) } const roofForm = ( after?: (page: Page) => Promise, overrides: Record = {}, exception?: DiffException, ) => addTranslation(roofEditor, FRENCH_TITLE_ROOF, after, overrides, exception) const duplicate = (overrides: Record) => addTranslation(pricingEditor, FRENCH_TITLE_PRICING, submitTranslation, overrides) const longSource = () => { const body = articleBody({ ...roofEditor }, 'en') return { status: 200, bodyFor: () => ({ ...body, cover_image_id: COVER_IMAGE_ID, images: Array.from({ length: IMAGE_COUNT }, (_, index) => ({ id: index + 1, url: `/images/${index + 1}.png`, })), authors: AUTHOR_NAMES.map((name, index) => ({ id: index + 1, name, slug: name.toLowerCase().replace(' ', '-'), })), }), } satisfies ApiMock } const post = (key: string, response: ApiMock) => ({ [`POST /v1/article/${key}/translations`]: response, }) export const newArticleLocalizationStates: Record = { 'loc.l3.single': modal((page) => chooseOption(page, 'Site', 'Archive')), 'loc.l3.multi': modal(async () => undefined), 'loc.l3.translation': modal(startTranslation, listWithMissing(), { maxPercent: { desktop: 10, mobile: 23.2 }, reason: 'The board draws no New article / Translation choice cards in translation mode, while the "Switching the choice keeps what is shared" scenario requires them (about 105px taller sheet); the board is to be updated.', approvedBy: 'Florian Sylvain', }), 'loc.l3.sitechange': modal(async (page) => { await modalTitle(page, 'Spring newsletter') await chooseOption(page, 'Language', 'French') await chooseOption(page, 'Site', 'Notes') }), 'loc.l4.form': roofForm(), 'loc.l4.empty': roofForm( async (page) => { await page.getByRole('radio', { name: /Start empty/ }).click() await settle(page) }, {}, { maxPercent: { desktop: 16.8, mobile: 32.2 }, reason: 'The board still draws the Source version field and copy note in Start empty, while features/localization_translation_flows.feature says they are no longer shown; the board is to be updated.', approvedBy: 'Florian Sylvain', }, ), 'loc.l4.saving': roofForm(submitTranslation, post('10', hangs)), 'loc.dup.readable': duplicate( post('10', { status: 409, body: 'language_exists', headers: { 'X-Existing-Article-Id': '12' }, }), ), 'loc.dup.hidden': duplicate(post('10', { status: 409, body: 'language_unavailable' })), 'loc.l4.fail': roofForm(submitTranslation, post('10', fails())), 'loc.l4.gone': pending('/article/10/edit?dialog=add-translation&lang=fr'), 'loc.l4.unreadable': roofForm( submitTranslation, post('10', { status: 404, body: 'source_unavailable' }), { maxPercent: { desktop: 12.1, mobile: 33.6 }, reason: 'The board omits the Starting content block and description, while the feature requires the radios so choosing Start empty clears the warning; the board is to be updated.', approvedBy: 'Florian Sylvain', }, ), 'loc.l4.langOff': roofForm(submitTranslation, { ...post('10', { status: 409, body: 'language_unavailable' }), 'GET /v1/site/journal/languages': { status: 200, bodyFor: (_url, call) => languagesOf(call === 1 ? ['en', 'fr', 'de', 'ar'] : ['en', 'de', 'ar']), }, }), 'loc.l4.long': addTranslation(roofEditor, FRENCH_TITLE_LONG, rewindDialog, { 'GET /v1/article/10': longSource(), }), }