diff --git a/e2e/tests/document-editing.spec.ts b/e2e/tests/document-editing.spec.ts new file mode 100644 index 0000000000..a552b6c925 --- /dev/null +++ b/e2e/tests/document-editing.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi } from './helpers'; + +test.describe('Document editor - typing and formatting', () => { + test('type text and apply bold/italic', async ({ page }) => { + const { editorPage, frame } = await openNewEditor(page, 'a.try-editor.word', /\.docx/); + + await editorPage.keyboard.type('Hello world'); + await editorPage.keyboard.press('Control+A'); + await editorPage.keyboard.press('Control+b'); + await editorPage.keyboard.press('Control+i'); + + // The edit registered: the undo button is no longer disabled. + await expect(frame.locator('#slot-btn-undo button').first()).not.toHaveClass(/disabled/); + + // The typed text round-trips through the automation API. + const text = await editorApi(editorPage, (api) => api.asc_GetSelectedText()); + expect(text).toContain('Hello world'); + }); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 0000000000..e799f207cb --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,87 @@ +import { Page, expect } from '@playwright/test'; + +const EDITOR_IFRAME = 'iframe[name="frameEditor"]'; + +/** + * Open the example page, create a new document of the requested type, and wait + * until the editor iframe has finished loading. Returns the editor tab (a new + * page) and a frameLocator scoped to the editor iframe. + */ +export async function openNewEditor(page: Page, selector: string, urlExt: RegExp) { + await page.goto('/example/'); + await expect(page).toHaveTitle(/ONLYOFFICE|euro-office/i); + + const newPagePromise = page.context().waitForEvent('page'); + await page.click(selector); + const editorPage = await newPagePromise; + + await editorPage.waitForURL(/\/example\/editor/); + await editorPage.waitForLoadState('domcontentloaded'); + await expect(editorPage).toHaveURL(urlExt); + + const editorIframe = editorPage.locator(EDITOR_IFRAME); + await expect(editorIframe).toBeAttached({ timeout: 15_000 }); + + const frame = editorPage.frameLocator(EDITOR_IFRAME); + await expect(frame.locator('#loading-mask')).toBeHidden({ timeout: 30_000 }); + + await frame.locator('#editor_sdk').click(); + return { editorPage, frame }; +} + +/** + * Evaluate a function against the editor's automation API (window.Asc.editor) + * inside the editor iframe. The function is serialized, so it must be + * self-contained (no closures over test scope). + */ +export async function editorApi(editorPage: Page, fn: (api: any) => T): Promise { + const handle = await editorPage.locator(EDITOR_IFRAME).elementHandle(); + if (!handle) throw new Error('editor iframe not found'); + const frame = await handle.contentFrame(); + if (!frame) throw new Error('editor iframe has no content frame'); + + return frame.evaluate((body) => { + const w = window as unknown as { Asc?: { editor?: unknown } }; + const api = w.Asc?.editor; + if (!api) throw new Error('window.Asc.editor not available'); + // eslint-disable-next-line no-new-func + return new Function('api', `return (${body})(api);`)(api); + }, fn.toString()); +} + +/** + * Evaluate a function against the editor iframe's window, for driving app-level + * globals (e.g. Common.UI.Themes) rather than just the automation API. The + * function is serialized, so it must be self-contained (no closures over test + * scope) -- but real, JSON-serializable values from the test can still be + * passed in via `args`, which arrive as genuine extra parameters (`fn(win, + * ...args)`), not string-substituted into the function's source text. + */ +export async function frameEval( + editorPage: Page, + fn: (win: any, ...args: any[]) => T, + args: any[] = [], +): Promise { + const handle = await editorPage.locator(EDITOR_IFRAME).elementHandle(); + if (!handle) throw new Error('editor iframe not found'); + const frame = await handle.contentFrame(); + if (!frame) throw new Error('editor iframe has no content frame'); + + return frame.evaluate(({ body, args }) => { + // eslint-disable-next-line no-new-func + return new Function('win', 'args', `return (${body}).apply(null, [win].concat(args));`)(window, args); + }, { body: fn.toString(), args }); +} + +/** + * Asserts an RGB color is within `tolerance` per channel of `expected`, not + * exactly equal. Anti-aliasing at a fill/glyph edge can shift a scanned + * pixel by a value or two from the exact color that was set, even when the + * color itself is correct. + */ +export function expectColorClose(actual: number[], expected: number[], tolerance = 4) { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +} diff --git a/e2e/tests/presentation-slides.spec.ts b/e2e/tests/presentation-slides.spec.ts new file mode 100644 index 0000000000..f2a6d2aa00 --- /dev/null +++ b/e2e/tests/presentation-slides.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi } from './helpers'; + +const slideCount = (p: import('@playwright/test').Page) => + editorApi(p, (api) => api.getCountPages()); + +test.describe('Presentation editor - building a deck', () => { + test('create a deck of 3 slides', async ({ page }) => { + const { editorPage, frame } = await openNewEditor(page, 'a.try-editor.slide', /\.pptx/); + + // A fresh presentation starts with a single slide. + expect(await slideCount(editorPage)).toBe(1); + + // The Ctrl+M shortcut only fires when the slide-thumbnail panel is focused + // and toolbar button ids are renamed in this build, so drive slide creation + // through the automation API (AddSlide) instead. + await editorApi(editorPage, (api) => { api.AddSlide(); api.AddSlide(); }); + + await expect.poll(() => slideCount(editorPage)).toBe(3); + + // The document was mutated: undo is enabled. + await expect(frame.locator('#slot-btn-undo button').first()).not.toHaveClass(/disabled/); + }); +}); diff --git a/e2e/tests/spreadsheet-formula.spec.ts b/e2e/tests/spreadsheet-formula.spec.ts new file mode 100644 index 0000000000..e56b6492e8 --- /dev/null +++ b/e2e/tests/spreadsheet-formula.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi } from './helpers'; + +const cellText = (editorPage: import('@playwright/test').Page) => + editorApi(editorPage, (api) => { + const info = api.asc_getCellInfo(); + return info && info.asc_getText ? info.asc_getText() : null; + }); + +test.describe('Spreadsheet editor - numbers and formulas', () => { + test('enter numbers and a formula', async ({ page }) => { + const { editorPage, frame } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + const nameBox = frame.locator('#ce-cell-name'); + const select = async (cell: string) => { + await nameBox.fill(cell); + await nameBox.press('Enter'); + }; + + // Ctrl+Home guarantees the grid has keyboard focus at A1 (the helper's + // canvas click can leave an arbitrary cell selected). + await editorPage.keyboard.press('Control+Home'); + await editorPage.keyboard.type('5'); + await editorPage.keyboard.press('Enter'); + await editorPage.keyboard.type('10'); + await editorPage.keyboard.press('Enter'); + await editorPage.keyboard.type('=A1+A2'); + await editorPage.keyboard.press('Enter'); + + // Operands landed in A1/A2. + await select('A1'); + expect(await cellText(editorPage)).toBe('5'); + await select('A2'); + expect(await cellText(editorPage)).toBe('10'); + + // The formula was accepted into A3. asc_getText() returns the formula + // string, not the computed value. + await select('A3'); + expect(await cellText(editorPage)).toBe('=A1+A2'); + + // TODO: verify the *computed* value (15). asc_getCellInfo().asc_getText() + // returns the formula, and pluginMethod_GetSelectedText() returns "" for + // cell selections, so neither reflects the result. The clipboard route + // (select A3 -> Ctrl+C -> navigator.clipboard.readText() === '15') works + // but needs clipboard-read/write permissions wired into the project config. + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/README.md b/e2e/tests/spreadsheeteditor/darkmode/README.md new file mode 100644 index 0000000000..43cd0b0dea --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/README.md @@ -0,0 +1,71 @@ +``` + /$$$$$$ /$$$$$$ /$$$$$$$$ /$$$$$$$ /$$ + /$$__ $$ /$$__ $$| $$_____/ | $$__ $$ | $$ +| $$ \__/| $$ \__/| $$ | $$ \ $$ /$$$$$$ /$$$$$$ | $$ /$$ +| $$$$$$ | $$$$$$ | $$$$$ | $$ | $$ |____ $$ /$$__ $$| $$ /$$/ + \____ $$ \____ $$| $$__/ | $$ | $$ /$$$$$$$| $$ \__/| $$$$$$/ + /$$ \ $$ /$$ \ $$| $$ | $$ | $$ /$$__ $$| $$ | $$_ $$ +| $$$$$$/| $$$$$$/| $$$$$$$$ | $$$$$$$/| $$$$$$$| $$ | $$ \ $$ + \______/ \______/ |________/ |_______/ \_______/|__/ |__/ \__/ + /$$ /$$ /$$ /$$$$$$$$ /$$ +| $$$ /$$$ | $$ |__ $$__/ | $$ +| $$$$ /$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ | $$ /$$$$$$ /$$$$$$$ /$$$$$$ +| $$ $$/$$ $$ /$$__ $$ /$$__ $$ /$$__ $$ | $$ /$$__ $$ /$$_____/|_ $$_/ +| $$ $$$| $$| $$ \ $$| $$ | $$| $$$$$$$$ | $$| $$$$$$$$| $$$$$$ | $$ +| $$\ $ | $$| $$ | $$| $$ | $$| $$_____/ | $$| $$_____/ \____ $$ | $$ /$$ +| $$ \/ | $$| $$$$$$/| $$$$$$$| $$$$$$$ | $$| $$$$$$$ /$$$$$$$/ | $$$$/ +|__/ |__/ \______/ \_______/ \_______/ |__/ \_______/|_______/ \___/ +``` + +## what we want to test + +UI and relation with darkmode +- button darkmode being disabled in light theme +- button darkmode being enabled in dark theme +- click on button darkmode enable darkmode +- interface theme's effect on content dark mode (forces off / restores) + +background and foreground colors cells +- 100% automatic colors , before/after darkmode +- automatic background color + user text color , before/after darkmode +- user background color + automatic text color , before/after darkmode +- user background color + user text color , before/after darkmode +- merged cells with automatic colors +- search-highlighted cell text contrast +- a cell actively being edited (mid-edit toggle) + +borders +- 100% automatic colors , before/after darkmode +- user/explicit color , before/after darkmode +- column resize guide visibility + +print (simplified test) +- backgroundcolor of document , before/after darkmode + +## running these tests + +Each file in this folder is a normal Playwright spec, discovered directly -- +no index, no special setup. + +```bash +# all of them +npx playwright test tests/spreadsheeteditor/darkmode/ + +# just one +npx playwright test tests/spreadsheeteditor/darkmode/resize-guide.spec.ts + +# by name +npx playwright test tests/spreadsheeteditor/darkmode/ -g "resize guide" + +# everything except one +npx playwright test tests/spreadsheeteditor/darkmode/ --grep-invert "resize guide" +``` + +If a test fails with `page.goto` timing out or a `test.describe()`-related error +that doesn't match the actual file content, clear Playwright's transform cache +before assuming it's a real bug -- this has recurred a few times after +renaming/moving spec files in this folder: + +```bash +rm -rf /tmp/playwright-transform-cache-* +``` diff --git a/e2e/tests/spreadsheeteditor/darkmode/cell-borders.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/cell-borders.spec.ts new file mode 100644 index 0000000000..a8280fba9f --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/cell-borders.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, frameEval, expectColorClose } from '../../helpers'; +import { bottomBorderPixel } from '../../utils/spreadsheet-editor'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + TESTING CELL BORDER COLOR CORRECTION + */ + test('automatic cell border color inverts in dark mode; explicit border color stays untouched', async ({ page }) => { + // C2 gets an automatic-colored bottom border, D2 an explicit one. + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + // asc_CBorder(style, color) -- omitting color leaves it automatic (null). + await editorApi(editorPage, (api) => { + api.asc_findCell('C2'); + const borders: any[] = []; + borders[(window as any).Asc.c_oAscBorderOptions.Bottom] = new (window as any).Asc.asc_CBorder((window as any).Asc.c_oAscBorderStyles.Thin); + api.asc_setCellBorders(borders); + }); + await editorApi(editorPage, (api) => { + api.asc_findCell('D2'); + const borders: any[] = []; + borders[(window as any).Asc.c_oAscBorderOptions.Bottom] = + new (window as any).Asc.asc_CBorder((window as any).Asc.c_oAscBorderStyles.Thin, new (window as any).Asc.asc_CColor(220, 20, 20)); + api.asc_setCellBorders(borders); + }); + + // asc_setCellBorders lands on the model a tick later -- poll rather than + // assume it's synchronous. Uses frameEval (not editorApi) since row/col + // need to travel as real args. + const bottomBorderWidth = (row: number, col: number) => + frameEval(editorPage, (win, row: number, col: number) => { + const b = win.Asc.editor.wb.getWorksheet().model.getRange3(row, col, row, col).getBorderFull(); + return b.b ? b.b.w : 0; + }, [row, col]); + await expect.poll(() => bottomBorderWidth(1, 2)).toBeGreaterThan(0); + await expect.poll(() => bottomBorderWidth(1, 3)).toBeGreaterThan(0); + + // MEASURE BORDER COLOR IN LIGHT MODE + + // C2's automatic border resolves to literal black by default, same as + // automatic text. + expectColorClose(await bottomBorderPixel(editorPage, 1, 2), [0, 0, 0]); + expectColorClose(await bottomBorderPixel(editorPage, 1, 3), [220, 20, 20]); + + // SWITCH TO DARK MODE + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + + // MEASURE BORDER COLOR IN DARK MODE + + // C2's automatic border inverts for contrast; D2's explicit border is + // exactly unchanged. + await expect.poll(() => bottomBorderPixel(editorPage, 1, 2)).toEqual([255, 255, 255]); + expectColorClose(await bottomBorderPixel(editorPage, 1, 3), [220, 20, 20]); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/cell-colors.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/cell-colors.spec.ts new file mode 100644 index 0000000000..5312127e5c --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/cell-colors.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, expectColorClose } from '../../helpers'; +import { cellFillRgb, cellFontRgb, sampleCellPixels } from '../../utils/spreadsheet-editor'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + TESTING CORE CELL COLOR CORRECTION + */ + test('automatic cell colors invert in dark mode; explicit colors stay untouched', async ({ page }) => { + // Covers the automatic/explicit combinations a cell's fill and text can + // be in: B2 (both automatic), B3 (fill only), B4 (both explicit), B6 + // (text only), B7 (light fill only). Measures each before/after + // toggling dark mode. Automatic text inverts for contrast against + // whichever background is actually behind it -- the dark canvas (B2), + // or its own cell's fill if it has one (B3, B7) -- but only when that + // background is dark enough to need it: B7's fill isn't, and neither + // is B3's, just under the current isColorDark cutoff. + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + // Assert the light-mode starting point explicitly rather than assume it + // -- every "before" check below relies on it. + expect(await editorApi(editorPage, (api) => api.isDarkMode)).toBe(false); + + // WRITE B2/B3/B4/B6/B7 + + // B2: left on default/automatic colors -- no fill, no explicit text color. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(1, 1, 1, 1).setValue('lorem'); + }); + + // B3: explicit fill only. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(2, 1, 2, 1).setValue('bgOnly'); + }); + + // asc_setCellBackgroundColor/asc_setCellTextColor land on the model a + // tick after the call returns -- poll rather than assume it's synchronous. + await editorApi(editorPage, (api) => { + api.asc_findCell('B3'); + api.asc_setCellBackgroundColor(new (window as any).Asc.asc_CColor(200, 100, 50)); + }); + await expect.poll(() => cellFillRgb(editorPage, 2, 1)).not.toBeNull(); + + // B4: explicit fill + explicit text color. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(3, 1, 3, 1).setValue('bgAndText'); + api.asc_findCell('B4'); + api.asc_setCellBackgroundColor(new (window as any).Asc.asc_CColor(30, 30, 120)); + }); + await expect.poll(() => cellFillRgb(editorPage, 3, 1)).not.toBeNull(); + + await editorApi(editorPage, (api) => { + api.asc_findCell('B4'); + api.asc_setCellTextColor(new (window as any).Asc.asc_CColor(255, 220, 0)); + }); + await expect.poll(() => cellFontRgb(editorPage, 3, 1)).not.toBeNull(); + + // B6: automatic fill (none) + explicit text color only. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(5, 1, 5, 1).setValue('textOnly'); + api.asc_findCell('B6'); + api.asc_setCellTextColor(new (window as any).Asc.asc_CColor(10, 10, 60)); + }); + await expect.poll(() => cellFontRgb(editorPage, 5, 1)).not.toBeNull(); + + // B7: light explicit fill, automatic text. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(6, 1, 6, 1).setValue('lightBgOnly'); + }); + await editorApi(editorPage, (api) => { + api.asc_findCell('B7'); + api.asc_setCellBackgroundColor(new (window as any).Asc.asc_CColor(220, 220, 220)); + }); + await expect.poll(() => cellFillRgb(editorPage, 6, 1)).not.toBeNull(); + + // MEASURE COLORS IN LIGHT MODE + + const b2Before = await sampleCellPixels(editorPage, 1, 1); + const b3Before = await sampleCellPixels(editorPage, 2, 1); + const b4Before = await sampleCellPixels(editorPage, 3, 1); + const b6Before = await sampleCellPixels(editorPage, 5, 1); + const b7Before = await sampleCellPixels(editorPage, 6, 1); + + // B2's automatic text is literal black against the light-mode canvas. + expectColorClose(b2Before.darkest, [0, 0, 0]); + // B3's explicit fill renders as set. + expectColorClose(b3Before.lightest, [200, 100, 50]); + // B4's explicit fill and explicit text both render as set. + expectColorClose(b4Before.darkest, [30, 30, 120]); + expectColorClose(b4Before.lightest, [255, 220, 0]); + // B6's explicit text renders as set; its automatic (no-fill) background + // is plain white in light mode. + expectColorClose(b6Before.darkest, [10, 10, 60]); + expectColorClose(b6Before.lightest, [255, 255, 255]); + // B7's light explicit fill renders as set; its automatic text is literal + // black, same as B2's. + expectColorClose(b7Before.darkest, [0, 0, 0]); + expectColorClose(b7Before.lightest, [220, 220, 220]); + + // SWITCH TO DARK MODE + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + + // MEASURE COLORS IN DARK MODE + + // Poll rather than sample immediately after the toggle -- draw() may not + // land within the same tick asc_setContentDarkMode returns in. + await expect.poll(async () => (await sampleCellPixels(editorPage, 1, 1)).lightest).toEqual([255, 255, 255]); + + const b3After = await sampleCellPixels(editorPage, 2, 1); + const b4After = await sampleCellPixels(editorPage, 3, 1); + const b6After = await sampleCellPixels(editorPage, 5, 1); + const b7After = await sampleCellPixels(editorPage, 6, 1); + + // Explicit fill/text colors are exactly unchanged by the dark-mode toggle. + expectColorClose(b4After.darkest, [30, 30, 120]); + expectColorClose(b4After.lightest, [255, 220, 0]); + // B3's fill (200,100,50, HSL lightness 125) sits above the current + // isColorDark cutoff, so its automatic text stays untouched -- nothing + // in this cell differs from light mode. + expectColorClose(b3After.darkest, [0, 0, 0]); + expectColorClose(b3After.lightest, [200, 100, 50]); + // B6's explicit text is still unchanged; its automatic background is now + // the dark canvas gray instead of white -- the two swap which slot + // (darkest/lightest) they land in, since the text color (sum 80) is + // darker than both the light-mode white background (sum 765) and the + // dark-mode gray background (sum 114), so it's "darkest" in both modes. + expectColorClose(b6After.darkest, [10, 10, 60]); + expectColorClose(b6After.lightest, [38, 38, 38]); + // B7's fill (220,220,220) is light enough (luminance ~220/255, well + // above the threshold) that automatic text must stay untouched, exactly + // as in light mode -- no swap, no inversion. + expectColorClose(b7After.darkest, [0, 0, 0]); + expectColorClose(b7After.lightest, [220, 220, 220]); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/interface-theme.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/interface-theme.spec.ts new file mode 100644 index 0000000000..7cfe9c9274 --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/interface-theme.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, frameEval } from '../../helpers'; + +test.describe('Spreadsheet editor - dark mode', () => { + /* + TESTING INTERFACE THEME'S EFFECT ON CONTENT DARK MODE + */ + test('interface theme forces content dark mode off, then restores it -- not fully independent', async ({ page }) => { + // Switching interface theme to light unconditionally forces content + // dark mode off; switching back to dark restores the remembered + // preference automatically. So it's "remembered, but suspended while + // the interface is light," not fully independent of interface theme. + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + await frameEval(editorPage, (win) => win.Common.UI.Themes.setTheme('theme-night')); + await frameEval(editorPage, (win) => win.Common.UI.Themes.setContentTheme('dark')); + await expect.poll(() => editorApi(editorPage, (api) => api.isDarkMode)).toBe(true); + + // SWITCH INTERFACE THEME TO LIGHT -- CONTENT DARK MODE FORCED OFF + + await frameEval(editorPage, (win) => win.Common.UI.Themes.setTheme('theme-classic-light')); + await expect.poll(() => editorApi(editorPage, (api) => api.isDarkMode)).toBe(false); + + // SWITCH INTERFACE THEME BACK TO DARK -- PREFERENCE IS RESTORED + + await frameEval(editorPage, (win) => win.Common.UI.Themes.setTheme('theme-night')); + await expect.poll(() => editorApi(editorPage, (api) => api.isDarkMode)).toBe(true); + }); + + /* + INTERFACE THEME SWITCH MUST NOT CORRUPT CONTENT DARK MODE'S OWN CELL COLORS + + GlobalSkin (the interface skin's live color object) is a direct reference + into EditorSkins["theme-light"]/["theme-dark"], not an independent copy. + Content dark mode's cell background/grid colors must stay at their own + fixed values regardless of which interface skin is active. + */ + test('switching interface theme does not change content dark mode\'s cell colors', async ({ page }) => { + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + const readCellColors = () => + frameEval(editorPage, (win) => { + const dark = (win as any).AscCommon.EditorSkins['theme-dark']; + return { background: dark.CellBackground, grid: dark.CellGrid }; + }); + + // TODO: uses the same internal-API shortcut as the test above + // (Common.UI.Themes.setTheme) rather than clicking the actual + // interface-theme picker in the UI -- verifies the color values + // themselves stay correct, not that the real UI control reaches this + // code path. A fuller e2e would drive the actual theme-switcher. + await frameEval(editorPage, (win) => win.Common.UI.Themes.setTheme('theme-contrast-dark')); + + expect(await readCellColors()).toEqual({ background: '#262626', grid: '#454545' }); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/merged-cells.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/merged-cells.spec.ts new file mode 100644 index 0000000000..d4b8768a60 --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/merged-cells.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, expectColorClose } from '../../helpers'; +import { sampleMergedCellPixels } from '../../utils/spreadsheet-editor'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + TESTING MERGED CELL COLOR CORRECTION + */ + test('merged cell with automatic colors inverts the same way a regular cell does', async ({ page }) => { + // A merged cell must follow the same automatic/explicit color rules as + // a regular cell, not some mismatched combination of the two. + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + // B2:C2 merged, left on automatic colors (no fill, no explicit text). + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(1, 1, 1, 1).setValue('merged text'); + api.asc_findCell('B2:C2'); + }); + await editorApi(editorPage, (api) => api.asc_mergeCells((window as any).Asc.c_oAscMergeOptions.Merge)); + + // asc_mergeCells lands on the model a tick later -- poll rather than + // assume it's synchronous. + await expect.poll(() => + editorApi(editorPage, (api) => { + const merged = api.wb.getWorksheet().model.getMergedByCell(1, 1); + return merged ? [merged.r1, merged.c1, merged.r2, merged.c2] : null; + }), + ).toEqual([1, 1, 1, 2]); + + // MEASURE COLORS IN LIGHT MODE + + const before = await sampleMergedCellPixels(editorPage, 1, 1, 2); + expectColorClose(before.darkest, [0, 0, 0]); + expectColorClose(before.lightest, [255, 255, 255]); + + // SWITCH TO DARK MODE + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + + // MEASURE COLORS IN DARK MODE + + // Text inverts to white, background inverts to the dark canvas gray -- + // the same pattern a regular automatic cell follows. + await expect.poll(async () => (await sampleMergedCellPixels(editorPage, 1, 1, 2)).lightest).toEqual([255, 255, 255]); + const after = await sampleMergedCellPixels(editorPage, 1, 1, 2); + expectColorClose(after.darkest, [38, 38, 38]); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/mid-edit-toggle.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/mid-edit-toggle.spec.ts new file mode 100644 index 0000000000..879b5bf4a2 --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/mid-edit-toggle.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi } from '../../helpers'; +import { sampleCellPixels, ceColors } from '../../utils/spreadsheet-editor'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + TESTING MID-EDIT DARK-MODE TOGGLE BEHAVIOR (ACCEPTED, NOT A BUG) + */ + test('a cell actively being edited does not live-update on a mid-edit dark-mode toggle', async ({ page }) => { + // Neither the cell editor's text nor its background updates live if + // dark mode is toggled mid-edit -- both are one-time snapshots taken + // when editing starts. This is intentional (a live-refresh fix was + // prototyped and deliberately reverted as not worth it for this narrow, + // self-correcting gap), so this pins the current behavior as a + // regression guard, not a gap to fix. + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + await editorPage.keyboard.press('Control+Home'); + await editorPage.keyboard.type('hello'); + + const before = await ceColors(editorPage); + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + // Confirm the toggle is genuinely in effect elsewhere (the main grid), + // so a lack of change in ce-canvas below can't be mistaken for the + // toggle simply not having landed yet. + await expect.poll(() => sampleCellPixels(editorPage, 5, 5).then((s) => s.darkest)).not.toEqual([255, 255, 255]); + + const after = await ceColors(editorPage); + expect(after).toEqual(before); + + // ENDING THE EDIT AND STARTING A NEW ONE DOES PICK UP THE NEW THEME + await editorPage.keyboard.press('Escape'); + await editorPage.keyboard.press('Control+Home'); + await editorPage.keyboard.type('world'); + await expect.poll(async () => (await ceColors(editorPage)).includes('38,38,38')).toBe(true); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/page-break-preview.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/page-break-preview.spec.ts new file mode 100644 index 0000000000..88bbd4dd4e --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/page-break-preview.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, frameEval, expectColorClose } from '../../helpers'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + PAGE BREAK PREVIEW, DARK MODE: OUTSIDE-PRINT-AREA OVERLAY MUST MATCH THE GRID-LINE COLOR + + The overlay outside the print range must use the same theme-resolved + border color as ordinary grid lines. A regression that corrects this + color a second time would paint a near-white overlay next to dark-grey + grid lines instead of blending into them. + */ + test('page-break-preview overlay color matches the grid-line border color in dark mode', async ({ page }) => { + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + // A single filled cell defines the print area (A1:B2) -- every other + // visible cell lands outside the print area once Page Break Preview is + // on, which is exactly the overlay this test targets. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(1, 1, 1, 1).setValue('hi'); + }); + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + await editorApi(editorPage, (api) => + api.asc_SetSheetViewType((window as any).Asc.c_oAscESheetViewType.pageBreakPreview), + ); + + const sample = () => + frameEval(editorPage, (win) => { + const ws = win.Asc.editor.wb.getWorksheet(); + // J20 -- well outside the A1:B2 print range, still on-screen at + // the default zoom/window size. + const col = 9; + const row = 19; + const rect = ws.getCellCoord(col, row); + const ctx = win.document.getElementById('ws-canvas').getContext('2d'); + const d = ctx.getImageData(Math.round(rect._x) + 3, Math.round(rect._y) + 3, 1, 1).data; + const border = ws.settings.cells.defaultState.border; + return { + outsideAreaPixel: [d[0], d[1], d[2]], + expectedBorder: [border.getR(), border.getG(), border.getB()], + isOutsidePrintArea: ws.pagesModeDataContains(col, row) === false, + }; + }); + + // Poll: the print-page layout is computed lazily on the first grid + // draw after switching view type, not synchronously on the API call. + await expect.poll(async () => (await sample()).isOutsidePrintArea).toBe(true); + + const { outsideAreaPixel, expectedBorder } = await sample(); + expectColorClose(outsideAreaPixel, expectedBorder); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/pattern-and-gradient-fill-cell-editor.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/pattern-and-gradient-fill-cell-editor.spec.ts new file mode 100644 index 0000000000..82f30e7c59 --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/pattern-and-gradient-fill-cell-editor.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi } from '../../helpers'; +import { cellFillRgb, cellHasGradientFill, ceColors } from '../../utils/spreadsheet-editor'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + GRADIENT-FILLED CELL, EDITED IN DARK MODE: TEXT MUST STAY READABLE + + The cell editor never actually renders a gradient/pattern fill behind the + text being edited (a separate, pre-existing simplification) -- it falls + back to painting the theme-resolved cells.defaultState.background instead, + which does track dark mode (white in light mode, #262626 in dark mode). + Before the fix, the automatic-text-color decision didn't know that + fallback had happened and kept its grid-path "unknown gradient contrast, + don't touch the text" exemption regardless -- so the text rendered at its + literal stored black, unreadable against the now-dark fallback background. + See TASKS/5-dark-theme-canvas-background/5.10-gradient-fill-editor-black-text. + */ + test('editing a gradient-filled cell keeps automatic text readable against the editor background', async ({ page }) => { + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + expect(await editorApi(editorPage, (api) => api.isDarkMode)).toBe(false); + + // B2: a value, and a gradient fill with no patternFill at all -- the + // specific shape that makes Fill.prototype.bg()/getSolidFill() return + // null (both only ever look at patternFill), which is what leads the + // editor to fall back to cells.defaultState.background in the first place. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(1, 1, 1, 1).setValue('gradient'); + api.asc_findCell('B2'); + }); + + await editorApi(editorPage, (api) => { + const w = window as any; + const fill = new w.Asc.asc_CFill2(); + const gradient = new w.Asc.asc_CGradientFill(); + const stop1 = new w.Asc.asc_CGradientStop(); + stop1.asc_setColor(new w.Asc.asc_CColor(80, 80, 200)); + stop1.asc_setPosition(0); + const stop2 = new w.Asc.asc_CGradientStop(); + stop2.asc_setColor(new w.Asc.asc_CColor(20, 20, 80)); + stop2.asc_setPosition(1); + gradient.asc_putGradientStops([stop1, stop2]); + fill.asc_setGradientFill(gradient); + api.asc_setCellFill(fill); + }); + await expect.poll(() => cellHasGradientFill(editorPage, 1, 1)).toBe(true); + + // START EDITING B2 IN LIGHT MODE -- ALREADY-WORKING BASELINE + + await editorPage.keyboard.press('F2'); + const lightColors = await ceColors(editorPage); + // Pre-existing, unrelated limitation: the editor shows a plain white + // background, not the actual gradient -- confirmed here as the baseline + // this test builds on, not something this fix changes. + expect(lightColors).toContain('255,255,255'); + // Automatic text renders black against that white background -- readable. + expect(lightColors).toContain('0,0,0'); + await editorPage.keyboard.press('Escape'); + + // SWITCH TO DARK MODE, START EDITING B2 AGAIN + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + await editorApi(editorPage, (api) => api.asc_findCell('B2')); + await editorPage.keyboard.press('F2'); + + const darkColors = await ceColors(editorPage); + // The editor's fallback background is cells.defaultState.background's + // dark-mode value (#262626 = 38,38,38) -- confirms the fallback is in + // effect, same as the no-fill case already covered by cell-colors.spec.ts. + expect(darkColors).toContain('38,38,38'); + // Before the fix: automatic text stayed literal black (0,0,0) here, + // unreadable against the 38,38,38 background. After the fix: the + // gradient-fallback case is checked against that now-known background + // like any other, so it gets corrected to a light color instead. + expect(darkColors).not.toContain('0,0,0'); + }); + + /* + PATTERN-FILLED CELL, EDITED IN DARK MODE: TEXT MUST STAY READABLE + + Unlike a gradient fill, a genuine pattern fill has a known foreground + color, and that's exactly what the editor paints as the background. + Automatic text must be corrected for contrast against that color like + any other known background, not treated as an unknown-contrast case. + */ + test('editing a pattern-filled cell keeps automatic text readable against its foreground color', async ({ page }) => { + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + expect(await editorApi(editorPage, (api) => api.isDarkMode)).toBe(false); + + // B3: a value, and a dark, genuinely-patterned fill (DarkHorizontal, + // not Solid) -- the shape that makes getSolidFill() null but bg() + // non-null, which is the specific mismatch this fix targets. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(2, 1, 2, 1).setValue('pattern'); + api.asc_findCell('B3'); + }); + + await editorApi(editorPage, (api) => { + const w = window as any; + const fill = new w.Asc.asc_CFill2(); + const pattern = new w.Asc.asc_CPatternFill(); + pattern.asc_setType(w.Asc.c_oAscPatternType.DarkHorizontal); + pattern.asc_setFgColor(new w.Asc.asc_CColor(30, 30, 90)); + pattern.asc_setBgColor(new w.Asc.asc_CColor(10, 10, 30)); + fill.asc_setPatternFill(pattern); + api.asc_setCellFill(fill); + }); + await expect.poll(() => cellFillRgb(editorPage, 2, 1)).not.toBeNull(); + + // START EDITING B3 IN LIGHT MODE -- ALREADY-WORKING BASELINE + + await editorPage.keyboard.press('F2'); + const lightColors = await ceColors(editorPage); + // The editor paints the pattern's foreground color flat, raw -- not + // corrected in either mode, confirmed here as the baseline. + expect(lightColors).toContain('30,30,90'); + expect(lightColors).toContain('0,0,0'); + await editorPage.keyboard.press('Escape'); + + // SWITCH TO DARK MODE, START EDITING B3 AGAIN + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + await editorApi(editorPage, (api) => api.asc_findCell('B3')); + await editorPage.keyboard.press('F2'); + + const darkColors = await ceColors(editorPage); + // Same foreground color as before -- the editor's background paint is + // mode-independent, unaffected by this fix. + expect(darkColors).toContain('30,30,90'); + // Before the fix: automatic text stayed literal black here too, + // unreadable against this dark fill. After the fix: the pattern's + // actual foreground color is checked like any other known background, + // so it gets corrected to a light color instead. + expect(darkColors).not.toContain('0,0,0'); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/print-preview.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/print-preview.spec.ts new file mode 100644 index 0000000000..1f17a454fe --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/print-preview.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, frameEval, expectColorClose } from '../../helpers'; + +test.describe('Spreadsheet editor - dark mode print', () => { + /* + TESTING PRINT PREVIEW STAYS LIGHT REGARDLESS OF CONTENT DARK MODE + */ + test('print preview stays in light mode regardless of content dark mode', async ({ page }) => { + // Print already renders through a rendering context with dark mode + // forced off. This confirms the print-preview canvas background stays + // white even with content dark mode on, using a cell whose text + // overflows across several empty neighboring cells -- a layout that + // once leaked dark-theme colors into print at the cell boundaries. + const { editorPage, frame } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(1, 2, 1, 2).setValue('automatic borders colors here overflowing text'); + }); + + // SWITCH TO DARK MODE + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + await expect.poll(() => editorApi(editorPage, (api) => api.isDarkMode)).toBe(true); + + // OPEN PRINT PREVIEW (FILE TAB -> "PRINT WITH PREVIEW") + + // Drives the real UI, not asc_initPrintPreview directly -- the panel's + // container element that the API call needs only exists via this flow. + await frame.locator('a[data-tab="file"]').click(); + await frame.locator('#fm-btn-print-with-preview').click(); + + // The preview canvas is created lazily once the panel shows -- wait for + // it rather than assume it's there immediately after the click. + const previewCanvas = frame.locator('#print-preview-canvas'); + await expect(previewCanvas).toBeAttached({ timeout: 10_000 }); + + // MEASURE PRINT PREVIEW BACKGROUND + + const corners = await frameEval(editorPage, (win) => { + const canvas = win.document.getElementById('print-preview-canvas') as HTMLCanvasElement; + const ctx = canvas.getContext('2d')!; + const px = (x: number, y: number) => Array.from(ctx.getImageData(x, y, 1, 1).data.slice(0, 3)); + return { topLeft: px(2, 2), bottomRight: px(canvas.width - 3, canvas.height - 3) }; + }); + + expectColorClose(corners.topLeft, [255, 255, 255]); + expectColorClose(corners.bottomRight, [255, 255, 255]); + + // TODO: this only checks the page background stays light, not that the + // specific black-bars artifact 5.2 fixed is absent at the cell-boundary + // lines the overflowing text crosses. A full regression guard for that + // would need to know the exact print-preview pixel coordinates of each + // cell boundary within the overflowing row, not yet worked out. + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/resize-guide.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/resize-guide.spec.ts new file mode 100644 index 0000000000..fb16382c62 --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/resize-guide.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, expectColorClose } from '../../helpers'; +import { resizeColumnGuideColor } from '../../utils/spreadsheet-editor'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + TESTING COLUMN RESIZE GUIDE VISIBILITY IN DARK MODE + */ + test('column resize guide is visible against the dark canvas, not near-black-on-black', async ({ page }) => { + // The resize-drag guide line was hardcoded to black, invisible against a + // dark canvas. This confirms it now uses a theme-aware color instead. + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + // Select a cell in a different column first: drawing the guide also + // repaints the current selection marquee, which would otherwise + // contaminate the sampled column if selection were nearby. + await editorApi(editorPage, (api) => api.asc_findCell('H2')); + + // MEASURE GUIDE COLOR IN LIGHT MODE + expectColorClose((await resizeColumnGuideColor(editorPage, 1))!, [0, 0, 0]); + + // SWITCH TO DARK MODE + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + + // MEASURE GUIDE COLOR IN DARK MODE + await expect.poll(() => resizeColumnGuideColor(editorPage, 1)).toEqual([204, 204, 204]); + }); + + // TODO: row resize (drawRowGuides) not covered here -- only the column + // guide (drawColumnGuides) was verified. The two are structurally + // near-identical in source, but that symmetry hasn't actually been + // checked live per this file's own working principle (don't assume + // symmetry between sides/axes without verifying each one). +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/search-highlight.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/search-highlight.spec.ts new file mode 100644 index 0000000000..6d4ee5d1b3 --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/search-highlight.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi } from '../../helpers'; +import { sampleCellPixels } from '../../utils/spreadsheet-editor'; + +test.describe('Spreadsheet editor - dark mode rendering', () => { + /* + TESTING SEARCH-HIGHLIGHT TEXT CONTRAST IN DARK MODE + */ + test('search-highlighted cell text stays readable in dark mode', async ({ page }) => { + // Automatic text on a search-highlighted cell must stay dark/readable + // against the highlight's own light yellow fill, in both light and dark + // mode -- not inverted to light just because dark mode is on. + const { editorPage } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + // Short text that stays within one cell's width -- longer text overflows + // into the empty neighbor cell, leaving no glyph ink to sample within + // this cell's own bounds. + await editorApi(editorPage, (api) => { + api.wb.getWorksheet().model.getRange3(1, 1, 1, 1).setValue('hi'); + }); + + await editorApi(editorPage, (api) => { + const options = new (window as any).Asc.asc_CFindOptions(); + options.asc_setFindWhat('hi'); + options.asc_setScanForward(true); + options.asc_setIsMatchCase(false); + options.asc_setIsWholeCell(false); + options.asc_setScanOnOnlySheet((window as any).Asc.c_oAscSearchBy.Sheet); + options.asc_setScanByRows(true); + options.asc_setLookIn((window as any).Asc.c_oAscFindLookIn.Value); + api.asc_findText(options); + api.asc_selectSearchingResults(true); + // Unlike a plain setValue(), the search calls don't trigger their own + // repaint -- without this, the cell samples as a uniform fill color + // with no glyph ink at all. + const ws = api.wb.getWorksheet(); + ws._cleanCellsTextMetricsCache(); + ws.draw(); + }); + + // Poll for the highlight to actually be showing rather than assume it + // landed synchronously. + const luminanceSum = (rgb: number[]) => rgb[0] + rgb[1] + rgb[2]; + await expect.poll(async () => luminanceSum((await sampleCellPixels(editorPage, 1, 1)).lightest)).toBeGreaterThan(400); + + // MEASURE TEXT/HIGHLIGHT CONTRAST IN LIGHT MODE + + const before = await sampleCellPixels(editorPage, 1, 1); + expect(luminanceSum(before.darkest)).toBeLessThan(100); + expect(luminanceSum(before.lightest)).toBeGreaterThan(400); + + // SWITCH TO DARK MODE + + await editorApi(editorPage, (api) => api.asc_setContentDarkMode(true)); + + // MEASURE TEXT/HIGHLIGHT CONTRAST IN DARK MODE + + // Text must stay dark/readable; the highlight fill stays a light color + // too (shifted to a muted yellow, not inverted to a dark canvas gray). + await expect.poll(async () => luminanceSum((await sampleCellPixels(editorPage, 1, 1)).darkest)).toBeLessThan(100); + const after = await sampleCellPixels(editorPage, 1, 1); + expect(luminanceSum(after.darkest)).toBeLessThan(100); + expect(luminanceSum(after.lightest)).toBeGreaterThan(300); + }); +}); diff --git a/e2e/tests/spreadsheeteditor/darkmode/toggle-button.spec.ts b/e2e/tests/spreadsheeteditor/darkmode/toggle-button.spec.ts new file mode 100644 index 0000000000..6f2d127cab --- /dev/null +++ b/e2e/tests/spreadsheeteditor/darkmode/toggle-button.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { openNewEditor, editorApi, frameEval } from '../../helpers'; + +test.describe('Spreadsheet editor - dark mode', () => { + /* + TESTING THE DARK DOCUMENT TOGGLE BUTTON + */ + test('toggling content dark mode flips the api flag and syncs the toolbar button', async ({ page }) => { + // The button is locked until the interface theme is dark. This clicks + // the real button (not the API directly) in both directions and checks + // its enabled/active state stays in sync with the underlying flag. + const { editorPage, frame } = await openNewEditor(page, 'a.try-editor.cell', /\.xlsx/); + + await frame.locator('a[data-tab="view"]').click(); + + const darkDocButton = frame.locator('#slot-btn-dark-document button').first(); + + // BUTTON STARTS DISABLED IN A FRESH (LIGHT-THEME) DOCUMENT + await expect(darkDocButton).toHaveClass(/disabled/); + expect(await editorApi(editorPage, (api) => api.isDarkMode)).toBe(false); + + // SWITCH INTERFACE THEME TO DARK -- BUTTON BECOMES ENABLED + await frameEval(editorPage, (win) => win.Common.UI.Themes.setTheme('theme-night')); + + await expect(darkDocButton).not.toHaveClass(/disabled/); + expect(await editorApi(editorPage, (api) => api.isDarkMode)).toBe(false); + + // CLICK THE BUTTON TO ENABLE DARK MODE + await darkDocButton.click(); + + await expect.poll(() => editorApi(editorPage, (api) => api.isDarkMode)).toBe(true); + await expect(darkDocButton).toHaveClass(/active/); + + // CLICK AGAIN TO SWITCH BACK TO LIGHT MODE + + // The click handler debounces rapid clicks for 500ms; a second click + // inside that window is dropped rather than toggling again. This wait + // is matched to that real, named constant, not a guess at timing. + await editorPage.waitForTimeout(500+250); + await darkDocButton.click(); + + await expect.poll(() => editorApi(editorPage, (api) => api.isDarkMode)).toBe(false); + await expect(darkDocButton).not.toHaveClass(/active/); + }); +}); diff --git a/e2e/tests/utils/spreadsheet-editor.ts b/e2e/tests/utils/spreadsheet-editor.ts new file mode 100644 index 0000000000..594bd6ee7b --- /dev/null +++ b/e2e/tests/utils/spreadsheet-editor.ts @@ -0,0 +1,182 @@ +import { Page } from '@playwright/test'; +import { frameEval } from '../helpers'; + +// row/col are passed through frameEval's `args` (real Playwright-serialized +// values, arrived as genuine function parameters), not baked into a string -- +// see helpers.ts's frameEval for how that avoids the closure-doesn't-survive- +// serialization problem without writing code as text. +export function cellFillRgb(editorPage: Page, row: number, col: number) { + return frameEval(editorPage, (win, row: number, col: number) => { + const fill = win.Asc.editor.wb.getWorksheet().model.getRange3(row, col, row, col).getFill(); + return fill && fill.patternFill && fill.patternFill.fgColor ? fill.patternFill.fgColor.rgb : null; + }, [row, col]); +} + +// Distinct from cellFillRgb: a gradient fill has no patternFill at all (the +// model's Fill keeps gradientFill and patternFill as separate, mutually-set +// members), so cellFillRgb's patternFill-only check always reports null for +// it -- this checks the gradientFill member directly, purely to confirm the +// fill landed on the model before proceeding, not to read its color. +export function cellHasGradientFill(editorPage: Page, row: number, col: number) { + return frameEval(editorPage, (win, row: number, col: number) => { + const fill = win.Asc.editor.wb.getWorksheet().model.getRange3(row, col, row, col).getFill(); + return !!(fill && fill.gradientFill); + }, [row, col]); +} + +export function cellFontRgb(editorPage: Page, row: number, col: number) { + return frameEval(editorPage, (win, row: number, col: number) => { + const color = win.Asc.editor.wb.getWorksheet().model.getRange3(row, col, row, col).getFont().getColor(); + return color ? color.rgb : null; + }, [row, col]); +} + +// Samples the darkest/lightest pixel within a cell's rendered rect, rather +// than a single fixed coordinate -- robust to exact glyph/font-metric +// position, which a one-shot pixel guess is not. +// +// The scanned region is inset from each edge of the cell's rect by the +// cell's actual border width (read from the model via getBorderFull(), +// which reports 0/1/2/3px for None/Thin/Medium/Thick -- WorkbookElems.js's +// BorderProp.setStyle), or 1px if the cell has no explicit border on that +// side -- the default gridline is hardcoded to exactly 1px in +// WorksheetView.prototype._drawGrid regardless of any cell's own border. +// This isn't a guessed margin: confirmed via browser-loop against two real +// contamination modes at the raw rect boundary -- (1) an unfilled cell's +// own last row/column can literally *be* the gridline pixel, and (2) two +// vertically adjacent cells' paint rects can meet such that the shared +// boundary row reads as the *next* cell's fill rather than this one's -- +// and the derived inset value (1px, for a cell with no explicit border) +// clears both exactly as well as an earlier, uninvestigated flat guess did. +// A scenario that specifically wants to measure the gridline/border color +// itself (see TASKS/5-dark-theme-canvas-background/5.8-e2e-dark-mode-coverage, +// scenario 2) needs the opposite: sample at/near the edge on purpose, not +// inset from it. +export function sampleCellPixels(editorPage: Page, row: number, col: number) { + return frameEval(editorPage, (win, row: number, col: number) => { + const ws = win.Asc.editor.wb.getWorksheet(); + const range = ws.model.getRange3(row, col, row, col); + const border = range.getBorderFull(); + const inset = Math.max(1, border.t.w, border.r.w, border.b.w, border.l.w); + const rect = ws.getCellCoord(col, row); + const x = Math.round(rect._x) + inset; + const y = Math.round(rect._y) + inset; + const w = Math.round(rect._width) - 2 * inset; + const h = Math.round(rect._height) - 2 * inset; + const ctx = win.document.getElementById('ws-canvas').getContext('2d'); + const img = ctx.getImageData(x, y, w, h).data; + let darkest = [255, 255, 255]; + let darkestLum = Infinity; + let lightest = [0, 0, 0]; + let lightestLum = -Infinity; + for (let i = 0; i < img.length; i += 4) { + const lum = img[i] + img[i + 1] + img[i + 2]; + if (lum < darkestLum) { darkestLum = lum; darkest = [img[i], img[i + 1], img[i + 2]]; } + if (lum > lightestLum) { lightestLum = lum; lightest = [img[i], img[i + 1], img[i + 2]]; } + } + return { darkest, lightest }; + }, [row, col]); +} + +// Samples the single pixel row a cell's own bottom border renders at. +// Confirmed via browser-loop, not assumed: for a Thin border (the only +// width tested so far), the border occupies exactly one row at +// `rect._y + rect._height - 1`, with plain background on every row above +// and below it -- no inset/tolerance needed the way sampleCellPixels needs +// for fill/text, since there's no adjacent-cell-bleed risk at this specific +// coordinate for a bottom border between two unfilled cells. Only the +// bottom side is implemented -- top/left/right were not verified to sit at +// the equivalent offset (the fill/gridline investigation for +// sampleCellPixels found left/top edges behave differently from +// right/bottom ones, so don't assume symmetry without checking each side +// live first). +export function bottomBorderPixel(editorPage: Page, row: number, col: number) { + return frameEval(editorPage, (win, row: number, col: number) => { + const ws = win.Asc.editor.wb.getWorksheet(); + const rect = ws.getCellCoord(col, row); + const x = Math.round(rect._x) + 5; + const y = Math.round(rect._y) + Math.round(rect._height) - 1; + const ctx = win.document.getElementById('ws-canvas').getContext('2d'); + const d = ctx.getImageData(x, y, 1, 1).data; + return [d[0], d[1], d[2]]; + }, [row, col]); +} + +// Same darkest/lightest-in-rect technique as sampleCellPixels, but across a +// horizontally merged cell's full combined width. getCellCoord itself is not +// merge-aware -- merging via asc_mergeCells changes the model (confirmed via +// browser-loop: ws.model.getMergedByCell reports the merged bbox correctly) +// but getCellCoord(col1, row) still returns only the anchor column's own +// (unmerged) width. The combined rect is computed manually: left edge from +// the anchor cell (col1), right edge from the last cell in the merge +// (col2)'s own right edge -- confirmed live to span the full merged area +// with no leftover internal column-boundary artifact. Only horizontal +// (same-row, multi-column) merges are implemented/verified; vertical merges +// were not tested. +export function sampleMergedCellPixels(editorPage: Page, row: number, col1: number, col2: number) { + return frameEval(editorPage, (win, row: number, col1: number, col2: number) => { + const ws = win.Asc.editor.wb.getWorksheet(); + const anchorBorder = ws.model.getRange3(row, col1, row, col1).getBorderFull(); + const inset = Math.max(1, anchorBorder.t.w, anchorBorder.r.w, anchorBorder.b.w, anchorBorder.l.w); + const rectStart = ws.getCellCoord(col1, row); + const rectEnd = ws.getCellCoord(col2, row); + const x = Math.round(rectStart._x) + inset; + const y = Math.round(rectStart._y) + inset; + const w = Math.round(rectEnd._x) + Math.round(rectEnd._width) - Math.round(rectStart._x) - 2 * inset; + const h = Math.round(rectStart._height) - 2 * inset; + const ctx = win.document.getElementById('ws-canvas').getContext('2d'); + const img = ctx.getImageData(x, y, w, h).data; + let darkest = [255, 255, 255]; + let darkestLum = Infinity; + let lightest = [0, 0, 0]; + let lightestLum = -Infinity; + for (let i = 0; i < img.length; i += 4) { + const lum = img[i] + img[i + 1] + img[i + 2]; + if (lum < darkestLum) { darkestLum = lum; darkest = [img[i], img[i + 1], img[i + 2]]; } + if (lum > lightestLum) { lightestLum = lum; lightest = [img[i], img[i + 1], img[i + 2]]; } + } + return { darkest, lightest }; + }, [row, col1, col2]); +} + +// Draws the column-resize drag guide (WorksheetView.prototype.drawColumnGuides +// -- the dotted vertical line shown while dragging a column border) directly, +// without simulating a real mouse drag, and samples its color. Confirmed via +// browser-loop: the guide draws to the *overlay* canvas (`ws-canvas-overlay`, +// this.overlayCtx in the source), not the main `ws-canvas` every other helper +// in this file samples -- a different canvas entirely. It's a 1px-wide +// dotted vertical line at `colLeft - 1`, dots landing on every odd row (0, +// 2, 4... are transparent gaps) -- scans a tall-enough strip to guarantee +// catching an "on" dot rather than gambling on one row. Calling +// drawColumnGuides also repaints the current selection's marquee as a side +// effect (WorksheetView.prototype._drawSelection, called internally right +// before the guide itself) -- confirmed this can contaminate the scan if the +// active cell's marquee happens to overlap the sampled column, so the caller +// should select a cell in a visibly different column first. +export function resizeColumnGuideColor(editorPage: Page, col: number) { + return frameEval(editorPage, (win, col: number) => { + const ws = win.Asc.editor.wb.getWorksheet(); + const colLeft = ws._getColLeft(col); + ws.drawColumnGuides(col, colLeft, 0, 0); + const ctx = win.document.getElementById('ws-canvas-overlay').getContext('2d'); + const img = ctx.getImageData(Math.round(colLeft) - 1, 0, 1, 30).data; + for (let i = 0; i < img.length; i += 4) { + if (img[i + 3] > 0) return [img[i], img[i + 1], img[i + 2]]; + } + return null; + }, [col]); +} + +// Every distinct RGB triple currently painted on the cell editor's own canvas, +// sorted -- used to assert the editor's rendered colors change (or don't) +// across a dark-mode toggle, independent of layout. +export function ceColors(editorPage: Page) { + return frameEval(editorPage, (win) => { + const canvas = win.document.getElementById('ce-canvas') as HTMLCanvasElement; + const ctx = canvas.getContext('2d')!; + const img = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + const set = new Set(); + for (let i = 0; i < img.length; i += 4) set.add(img[i] + ',' + img[i + 1] + ',' + img[i + 2]); + return Array.from(set).sort(); + }); +}