fix: 장수일람 특기 설명 줄바꿈을 공통 처리한다
This commit is contained in:
@@ -710,6 +710,106 @@ test('trait and injury explanations appear on pointer hover and keyboard focus',
|
||||
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
|
||||
});
|
||||
|
||||
for (const width of [1200, 500]) {
|
||||
test(`catalog description line breaks stay safe on hover and focus at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const warInfo =
|
||||
'[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p';
|
||||
const literalMarkup = '<img src=x onerror="alert(1)"><b>설명</b>';
|
||||
const directoryRows = generals.map((general) =>
|
||||
general.id === 10
|
||||
? {
|
||||
...general,
|
||||
specialDomestic: {
|
||||
...general.specialDomestic,
|
||||
info: `상업 특기<br/>수익 증가<BR />매매 효율\r\n${literalMarkup}`,
|
||||
},
|
||||
specialWar: { key: 'che_신산', name: '신산', info: warInfo },
|
||||
}
|
||||
: general
|
||||
);
|
||||
await install(page, 'general', [], [], directoryRows);
|
||||
await page.goto('general-list');
|
||||
await page.reload();
|
||||
const prefix = width <= 600 ? 'card-' : '';
|
||||
const container = page
|
||||
.locator(width <= 600 ? '[data-general-card-id="10"]' : 'tr[data-general-id="10"]')
|
||||
.first();
|
||||
await expect(container).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '갱 신' })).not.toHaveAttribute('aria-busy');
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all(Array.from(document.images, (image) => image.decode().catch(() => undefined)));
|
||||
});
|
||||
const before = await container.boundingBox();
|
||||
const evidence: Record<string, unknown> = { width, url: page.url(), before };
|
||||
for (const [kind, expected] of [
|
||||
['special-domestic', ['상업 특기', '수익 증가', '매매 효율', literalMarkup]],
|
||||
['special-war', warInfo.split('<br>')],
|
||||
] as const) {
|
||||
const trigger = page.locator(`[data-directory-tooltip="${prefix}${kind}-10"]`);
|
||||
const tooltip = trigger.getByRole('tooltip');
|
||||
await page.mouse.move(0, 0);
|
||||
await trigger.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await trigger.focus();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(trigger).toHaveCSS('text-decoration-line', 'none');
|
||||
await expect(trigger).toHaveCSS('outline-style', 'solid');
|
||||
const description = tooltip.locator('.directory-tooltip__description');
|
||||
await expect(description.locator('br')).toHaveCount(expected.length - 1);
|
||||
await expect(description.locator('img, b, script')).toHaveCount(0);
|
||||
expect(await description.innerText()).not.toMatch(/<br\s*\/?\s*>/iu);
|
||||
const metrics = await description.evaluate((element) => {
|
||||
const lines = Array.from(element.childNodes).filter(
|
||||
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
|
||||
);
|
||||
return {
|
||||
lines: lines.map((node) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
return { text: node.textContent?.trim(), top: range.getBoundingClientRect().top };
|
||||
}),
|
||||
html: element.outerHTML,
|
||||
};
|
||||
});
|
||||
expect(metrics.lines.map((line) => line.text)).toEqual(expected);
|
||||
for (let index = 1; index < metrics.lines.length; index += 1) {
|
||||
expect(metrics.lines[index]!.top).toBeGreaterThan(metrics.lines[index - 1]!.top);
|
||||
}
|
||||
const geometry = await tooltip.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
font: style.font,
|
||||
background: style.backgroundColor,
|
||||
position: style.position,
|
||||
};
|
||||
});
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(8);
|
||||
expect(geometry.right).toBeLessThanOrEqual(width - 8);
|
||||
expect(geometry.top).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.bottom).toBeLessThanOrEqual(900);
|
||||
expect(await container.boundingBox()).toEqual(before);
|
||||
evidence[kind] = { ...metrics, geometry };
|
||||
await page.screenshot({ path: testInfo.outputPath(`catalog-${kind}-${width}.png`), fullPage: true });
|
||||
await trigger.blur();
|
||||
}
|
||||
const empty = page.locator(`[data-directory-tooltip="${prefix}special-war-20"]`);
|
||||
await expect(empty).not.toHaveAttribute('tabindex');
|
||||
await expect(empty.getByRole('tooltip')).toHaveCount(0);
|
||||
await writeFile(testInfo.outputPath('catalog-description.json'), JSON.stringify(evidence, null, 2));
|
||||
await writeFile(
|
||||
testInfo.outputPath('catalog-description.html'),
|
||||
await page.locator('main').evaluate((element) => element.outerHTML)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.goto('npc-list');
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue';
|
||||
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
description?: string | null;
|
||||
testId?: string;
|
||||
}>();
|
||||
|
||||
const descriptionLines = computed(() => splitLegacyLineBreaks(props.description ?? ''));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,7 +21,10 @@ defineProps<{
|
||||
<slot />
|
||||
<span v-if="description" class="directory-tooltip__content" role="tooltip">
|
||||
<strong>{{ title }}</strong>
|
||||
<span>{{ description }}</span>
|
||||
<span class="directory-tooltip__description"
|
||||
><template v-for="(line, index) in descriptionLines" :key="index"
|
||||
>{{ line }}<br v-if="index < descriptionLines.length - 1" /></template
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
|
||||
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
|
||||
import tippy, { type Instance, type Placement } from 'tippy.js';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
@@ -28,7 +29,7 @@ let instance: Instance | null = null;
|
||||
const descriptionLines = computed(() => {
|
||||
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
|
||||
return source
|
||||
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
|
||||
.flatMap(splitLegacyLineBreaks)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 카탈로그 설명의 Ref 줄바꿈만 해석한다. 반환값은 반드시 text interpolation으로
|
||||
* 렌더링하며 HTML로 삽입하지 않는다. 빈 줄과 공백의 표시 정책은 소비자가 정한다.
|
||||
*/
|
||||
export const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r\n?|\n/giu);
|
||||
@@ -4,6 +4,7 @@ import { usePageExit } from '../composables/usePageExit';
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { splitLegacyLineBreaks } from '../utils/legacyDescription';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
@@ -207,7 +208,6 @@ const specialNameMap = computed(() => {
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r?\n/giu);
|
||||
const selectedSpecialWarInfo = computed(
|
||||
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? ''
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { splitLegacyLineBreaks } from '../src/utils/legacyDescription.ts';
|
||||
|
||||
void describe('legacy catalog description line breaks', () => {
|
||||
void it('accepts catalog br variants and platform newlines', () => {
|
||||
assert.deepEqual(splitLegacyLineBreaks('가<br>나<BR/>다<br />라\r\n마\n바\r사'), [
|
||||
'가',
|
||||
'나',
|
||||
'다',
|
||||
'라',
|
||||
'마',
|
||||
'바',
|
||||
'사',
|
||||
]);
|
||||
});
|
||||
|
||||
void it('preserves empty lines, whitespace, and unformatted descriptions', () => {
|
||||
assert.deepEqual(splitLegacyLineBreaks(''), ['']);
|
||||
assert.deepEqual(splitLegacyLineBreaks(' 일반 설명 '), [' 일반 설명 ']);
|
||||
assert.deepEqual(splitLegacyLineBreaks('<br>첫 줄<br><br>끝 줄\n'), ['', '첫 줄', '', '끝 줄', '']);
|
||||
});
|
||||
|
||||
void it('leaves other markup, attributes, and entities as literal text', () => {
|
||||
const text = '<img src=x onerror="alert(1)"><b>문구</b><br><br onclick="alert(1)">';
|
||||
assert.deepEqual(splitLegacyLineBreaks(text), [text]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user