mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 23:42:21 +02:00
368 lines
19 KiB
JavaScript
368 lines
19 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
|
const {
|
|
dragOnCanvas,
|
|
encodedImagePixelDigest,
|
|
editorState,
|
|
flattenedPixelDigest,
|
|
openBlankEditor,
|
|
reopenDraft,
|
|
waitForDraft,
|
|
} = require('./helpers.js');
|
|
|
|
async function addAdjustment(page, type) {
|
|
await page.locator('#ge-add-layer').click();
|
|
const menu = page.locator('.ge-add-layer-menu');
|
|
await expect(menu).toBeVisible();
|
|
await menu.locator(`[data-adjustment-type="${type}"]`).click();
|
|
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
|
}
|
|
|
|
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
|
|
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
|
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
|
|
const before = await flattenedPixelDigest(page);
|
|
|
|
await addAdjustment(page, 'levels');
|
|
await page.locator('.ge-adj-channel-select').selectOption('red');
|
|
await page.locator('.ge-adj-row input[data-key="outWhite"]').fill('128');
|
|
await page.locator('.ge-adj-row input[data-key="outWhite"]').dispatchEvent('input');
|
|
await page.locator('[data-adj-action="ok"]').click();
|
|
|
|
let current = await editorState(page);
|
|
const levels = current.layers.find(layer => layer.kind === 'adjustment');
|
|
expect(levels.adjustment.type).toBe('levels');
|
|
expect(levels.adjustment.params.channels.red.outWhite).toBe(128);
|
|
expect(await flattenedPixelDigest(page)).not.toEqual(before);
|
|
|
|
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
|
|
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
|
|
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
|
|
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
|
|
current = await editorState(page);
|
|
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
|
|
|
|
await row.locator('.ge-layer-vis').click();
|
|
expect(await flattenedPixelDigest(page)).toEqual(before);
|
|
await row.locator('.ge-layer-vis').click();
|
|
await row.locator('.ge-layer-opacity').fill('45');
|
|
await row.locator('.ge-layer-opacity').dispatchEvent('input');
|
|
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
|
|
|
|
await page.locator('#ge-undo').click();
|
|
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBe(1);
|
|
await page.locator('#ge-redo').click();
|
|
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
|
|
|
|
const draftId = await waitForDraft(page);
|
|
const expected = await editorState(page);
|
|
const expectedPixels = await flattenedPixelDigest(page);
|
|
const exportedPng = await page.evaluate(async () => {
|
|
const editor = await import('/static/js/galleryEditor.js');
|
|
return editor.exportPNG();
|
|
});
|
|
expect(await encodedImagePixelDigest(
|
|
page,
|
|
Buffer.from(exportedPng.split(',', 2)[1], 'base64'),
|
|
)).toEqual(expectedPixels);
|
|
await reopenDraft(page, draftId);
|
|
current = await editorState(page);
|
|
expect(current.layers.find(layer => layer.id === levels.id)).toEqual(expected.layers.find(layer => layer.id === levels.id));
|
|
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
|
|
});
|
|
|
|
test('Curves supports editable RGB and channel points with reset and cancel', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 360, height: 260 }, 'Curves E2E');
|
|
await addAdjustment(page, 'curves');
|
|
const curve = page.locator('.ge-curves-canvas');
|
|
const box = await curve.boundingBox();
|
|
await page.mouse.click(box.x + box.width * .5, box.y + box.height * .28);
|
|
await page.locator('.ge-adj-channel-select').selectOption('blue');
|
|
const blueBox = await curve.boundingBox();
|
|
await page.mouse.click(blueBox.x + blueBox.width * .4, blueBox.y + blueBox.height * .68);
|
|
await page.locator('[data-adj-action="ok"]').click();
|
|
|
|
let current = await editorState(page);
|
|
const curves = current.layers.find(layer => layer.kind === 'adjustment');
|
|
expect(curves.adjustment.type).toBe('curves');
|
|
expect(curves.adjustment.params.points.rgb).toHaveLength(3);
|
|
expect(curves.adjustment.params.points.blue).toHaveLength(3);
|
|
|
|
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
|
|
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
|
|
await page.locator('[data-adj-action="reset"]').click();
|
|
await page.locator('[data-adj-action="cancel"]').click();
|
|
current = await editorState(page);
|
|
expect(current.layers.find(layer => layer.id === curves.id).adjustment.params.points.rgb).toHaveLength(3);
|
|
});
|
|
|
|
test('color adjustment controls retain neutral reusable parameters', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Color adjustments E2E');
|
|
await page.evaluate(async () => {
|
|
const { state } = await import('/static/js/editor/state.js');
|
|
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
|
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
|
|
gradient.addColorStop(0, '#b8324f');
|
|
gradient.addColorStop(.5, '#56a56d');
|
|
gradient.addColorStop(1, '#315fc4');
|
|
layer.ctx.fillStyle = gradient;
|
|
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
|
|
window.galleryEditorComposite?.();
|
|
});
|
|
|
|
const cases = [
|
|
{ type: 'exposure', key: 'exposure', value: '100', check: p => p.exposure === 1 },
|
|
{ type: 'white-balance', key: 'temperature', value: '70', check: p => p.temperature === 70 },
|
|
{ type: 'hue-saturation', key: 'lightness', value: '18', check: p => p.lightness === 18 },
|
|
{ type: 'vibrance', key: 'vibrance', value: '55', check: p => p.vibrance === 55 },
|
|
{ type: 'black-white', key: 'red', value: '72', compare: true, check: p => p.red === 72 && p.green === 59 && p.blue === 11 },
|
|
{ type: 'shadows-highlights', key: 'shadows', value: '48', check: p => p.shadows === 48 },
|
|
{ type: 'color-balance', key: 'shadows-r', value: '45', check: p => p.shadows.r === 45 },
|
|
{ type: 'selective-color', key: 'cyan', value: '35', check: p => p.ranges.reds.cyan === 35 },
|
|
];
|
|
let priorDigest = await flattenedPixelDigest(page);
|
|
for (const item of cases) {
|
|
await addAdjustment(page, item.type);
|
|
const control = page.locator(`.ge-adj-row input[data-key="${item.key}"]`);
|
|
await control.fill(item.value);
|
|
await control.dispatchEvent('input');
|
|
await page.locator('[data-adj-action="ok"]').click();
|
|
const current = await editorState(page);
|
|
const added = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
|
|
expect(added.adjustment.type).toBe(item.type);
|
|
expect(item.check(added.adjustment.params)).toBe(true);
|
|
const nextDigest = await flattenedPixelDigest(page);
|
|
expect(nextDigest).not.toBe(priorDigest);
|
|
if (item.compare) {
|
|
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
|
|
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
|
await page.locator('[data-adj-action="compare"]').click();
|
|
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
|
|
await page.locator('[data-adj-action="compare"]').click();
|
|
expect(await flattenedPixelDigest(page)).toEqual(nextDigest);
|
|
await page.locator('[data-adj-action="cancel"]').click();
|
|
}
|
|
priorDigest = nextDigest;
|
|
}
|
|
|
|
await addAdjustment(page, 'gradient-map');
|
|
await page.locator('[data-gradient-key="shadows"]').fill('#123456');
|
|
await page.locator('[data-gradient-key="highlights"]').fill('#f0d080');
|
|
await page.locator('[data-gradient-reverse]').check();
|
|
await page.locator('[data-adj-action="ok"]').click();
|
|
const current = await editorState(page);
|
|
const gradient = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
|
|
expect(gradient.adjustment).toEqual({
|
|
type: 'gradient-map',
|
|
params: { shadows: '#123456', highlights: '#f0d080', midpoint: 50, reverse: true },
|
|
});
|
|
const expectedPixels = await flattenedPixelDigest(page);
|
|
const draftId = await waitForDraft(page);
|
|
const expectedState = await editorState(page);
|
|
await reopenDraft(page, draftId);
|
|
const reopened = await editorState(page);
|
|
expect(reopened.layers.filter(layer => layer.kind === 'adjustment')).toEqual(
|
|
expectedState.layers.filter(layer => layer.kind === 'adjustment'),
|
|
);
|
|
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
|
|
});
|
|
|
|
test('adjustment presets apply through the retained popup', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Adjustment presets E2E');
|
|
await page.evaluate(async () => {
|
|
const { state } = await import('/static/js/editor/state.js');
|
|
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
|
layer.ctx.fillStyle = '#5577aa';
|
|
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
|
|
window.galleryEditorComposite?.();
|
|
});
|
|
const before = await flattenedPixelDigest(page);
|
|
await addAdjustment(page, 'exposure');
|
|
await page.locator('.ge-adj-preset-select').selectOption('Lift Exposure');
|
|
await page.locator('[data-adj-action="ok"]').click();
|
|
const current = await editorState(page);
|
|
const exposure = current.layers.find(layer => layer.kind === 'adjustment');
|
|
expect(exposure.adjustment.params.exposure).toBe(0.45);
|
|
expect(await flattenedPixelDigest(page)).not.toEqual(before);
|
|
});
|
|
|
|
test('adjustment popup keeps controls inside a narrow phone viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 320, height: 700 });
|
|
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment E2E');
|
|
await addAdjustment(page, 'exposure');
|
|
|
|
const popup = await page.locator('.ge-adj-popup').boundingBox();
|
|
expect(popup).not.toBeNull();
|
|
expect(popup.x).toBeGreaterThanOrEqual(0);
|
|
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
|
|
for (const row of await page.locator('.ge-adj-row').all()) {
|
|
const box = await row.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
expect(box.x).toBeGreaterThanOrEqual(popup.x);
|
|
expect(box.x + box.width).toBeLessThanOrEqual(popup.x + popup.width);
|
|
}
|
|
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
|
|
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
|
|
});
|
|
|
|
test('gradient map controls remain usable on a narrow phone viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 320, height: 700 });
|
|
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile gradient map E2E');
|
|
await addAdjustment(page, 'gradient-map');
|
|
|
|
const popup = await page.locator('.ge-adj-popup').boundingBox();
|
|
const colors = page.locator('.ge-gradient-color-row');
|
|
const colorBox = await colors.boundingBox();
|
|
expect(popup).not.toBeNull();
|
|
expect(colorBox).not.toBeNull();
|
|
expect(colorBox.x).toBeGreaterThanOrEqual(popup.x);
|
|
expect(colorBox.x + colorBox.width).toBeLessThanOrEqual(popup.x + popup.width);
|
|
expect(await page.locator('.ge-gradient-color-row').evaluate(el => (
|
|
getComputedStyle(el).gridTemplateColumns.trim().split(/\s+/).length
|
|
))).toBe(1);
|
|
});
|
|
|
|
test('all adjustment popups remain usable on a narrow phone viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 320, height: 700 });
|
|
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment matrix E2E');
|
|
|
|
for (const type of [
|
|
'brightness-contrast', 'exposure', 'white-balance', 'hue-saturation',
|
|
'vibrance', 'black-white', 'shadows-highlights', 'levels', 'curves',
|
|
'color-balance', 'selective-color', 'gradient-map',
|
|
]) {
|
|
await addAdjustment(page, type);
|
|
const popup = await page.locator('.ge-adj-popup').boundingBox();
|
|
expect(popup, `${type} popup should be visible`).not.toBeNull();
|
|
expect(popup.x).toBeGreaterThanOrEqual(0);
|
|
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
|
|
|
|
const body = page.locator('.ge-adj-body');
|
|
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth + 1), `${type} body should not overflow horizontally`).toBe(true);
|
|
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
|
|
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
|
|
await page.locator('[data-adj-action="cancel"]').click();
|
|
}
|
|
});
|
|
|
|
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
|
|
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
|
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
|
|
const before = await flattenedPixelDigest(page);
|
|
|
|
await page.locator('#ge-filter-menu-btn').click();
|
|
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
|
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
|
|
|
let current = await editorState(page);
|
|
const layer = current.layers.find(item => item.effects?.length);
|
|
expect(layer.effects).toHaveLength(1);
|
|
expect(layer.effects[0].type).toBe('gaussian-blur');
|
|
expect(layer.effects[0].params.radius).toBe(14);
|
|
const after = await flattenedPixelDigest(page);
|
|
expect(after).not.toEqual(before);
|
|
|
|
const draftId = await waitForDraft(page);
|
|
await reopenDraft(page, draftId);
|
|
current = await editorState(page);
|
|
const reopened = current.layers.find(item => item.effects?.length);
|
|
expect(reopened.effects).toEqual(layer.effects);
|
|
expect(await flattenedPixelDigest(page)).toEqual(after);
|
|
|
|
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
|
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
|
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
|
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
|
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].params.radius).toBe(22);
|
|
});
|
|
|
|
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
|
|
await page.locator('#ge-filter-menu-btn').click();
|
|
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
|
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
|
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
|
|
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
|
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
|
let current = await editorState(page);
|
|
let layer = current.layers.find(item => item.effects?.length);
|
|
expect(layer.effects[0].type).toBe('sharpen');
|
|
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
|
|
const draftId = await waitForDraft(page);
|
|
await reopenDraft(page, draftId);
|
|
current = await editorState(page);
|
|
layer = current.layers.find(item => item.effects?.length);
|
|
expect(layer.effects[0].type).toBe('sharpen');
|
|
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
|
|
});
|
|
|
|
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
|
|
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
|
|
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
|
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
|
|
await page.mouse.down();
|
|
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
|
|
await page.mouse.up();
|
|
await page.locator('#ge-filter-menu-btn').click();
|
|
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
|
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
|
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
|
await page.locator('button[title="Add effect mask from selection"]').click();
|
|
|
|
await page.locator('#ge-filter-menu-btn').click();
|
|
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
|
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
|
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
|
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
|
|
|
let current = await editorState(page);
|
|
let layer = current.layers.find(item => item.effects?.length);
|
|
expect(layer.effects.map(effect => effect.type)).toEqual(['color-overlay', 'drop-shadow']);
|
|
expect(layer.effects[0].params.color).toBe('#336699');
|
|
expect(layer.effects[0].mask.size).toEqual([320, 220]);
|
|
await page.locator('button[title="Hide effect mask"]').click();
|
|
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask.visible).toBe(false);
|
|
await page.locator('button[title="Remove effect mask"]').click();
|
|
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask).toBeNull();
|
|
expect(layer.effects[0].params.opacity).toBeCloseTo(0.35, 2);
|
|
expect(layer.effects[1].params.blur).toBe(18);
|
|
|
|
await page.locator('.ge-effect-sub-item').nth(0).locator('.ge-layer-vis').click();
|
|
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].visible).toBe(false);
|
|
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Move effect up"]').click();
|
|
current = await editorState(page);
|
|
layer = current.layers.find(item => item.effects?.length);
|
|
expect(layer.effects.map(effect => effect.type)).toEqual(['drop-shadow', 'color-overlay']);
|
|
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Delete effect"]').click();
|
|
expect((await editorState(page)).layers.find(item => item.effects?.length).effects).toHaveLength(1);
|
|
});
|
|
|
|
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
|
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
|
|
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
|
|
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
|
|
await page.locator('#ge-filter-menu-btn').click();
|
|
await page.locator('[data-filter-action="effect-stroke"]').click();
|
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
|
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
|
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
|
const layer = (await editorState(page)).layers.find(item => item.effects?.length);
|
|
expect(layer.effects[0].type).toBe('stroke');
|
|
expect(layer.effects[0].params.width).toBe(9);
|
|
const beforeRasterize = await flattenedPixelDigest(page);
|
|
await page.locator('.ge-effect-sub-item button[title="Rasterize effects"]').click();
|
|
await expect.poll(async () => (await editorState(page)).layers.find(item => item.name === 'Edit').effects.length).toBe(0);
|
|
const rasterized = (await editorState(page)).layers.find(item => item.name === 'Edit');
|
|
expect(rasterized.effects).toHaveLength(0);
|
|
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
|
|
const draftId = await waitForDraft(page);
|
|
await reopenDraft(page, draftId);
|
|
expect((await editorState(page)).layers.find(item => item.name === 'Edit').effects).toHaveLength(0);
|
|
});
|