fix(frontend): match nation betting reference layout
This commit is contained in:
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listItems = computed(() =>
|
const listItems = computed(() =>
|
||||||
list.value
|
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
|
||||||
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
|
|
||||||
: []
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const info = computed(() => detail.value?.bettingInfo ?? null);
|
const info = computed(() => detail.value?.bettingInfo ?? null);
|
||||||
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
|
|||||||
|
|
||||||
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
||||||
|
|
||||||
const totalAmount = computed(() =>
|
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
|
||||||
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const pureAmount = computed(() =>
|
const pureAmount = computed(() =>
|
||||||
(detail.value?.bettingDetail ?? []).reduce(
|
(detail.value?.bettingDetail ?? []).reduce(
|
||||||
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const usedAmount = computed(() =>
|
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
|
||||||
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
|
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
|
||||||
|
|
||||||
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
|
|||||||
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseYearMonth = (yearMonth: number): [number, number] => [
|
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
|
||||||
Math.floor(yearMonth / 12),
|
|
||||||
(yearMonth % 12) + 1,
|
|
||||||
];
|
|
||||||
|
|
||||||
const readSelection = (value: string): number[] => {
|
const readSelection = (value: string): number[] => {
|
||||||
try {
|
try {
|
||||||
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
|
|||||||
.map((index) => candidates.value[index]?.title ?? '-')
|
.map((index) => candidates.value[index]?.title ?? '-')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
const isListOpen = (item: BettingListItem): boolean =>
|
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
|
||||||
!item.finished && listYearMonth.value <= item.closeYearMonth;
|
|
||||||
|
|
||||||
const isDetailOpen = computed(() =>
|
const isDetailOpen = computed(() =>
|
||||||
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
|
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
|
||||||
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
|
|||||||
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
|
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedMultiplier = (key: string, betAmount: number): string => {
|
const rewardByMatch = computed(() => {
|
||||||
if (betAmount <= 0) {
|
const selectCount = info.value?.selectCnt ?? 0;
|
||||||
return '0.0';
|
const rewards = new Array<number>(selectCount + 1).fill(0);
|
||||||
|
if (selectCount <= 0) {
|
||||||
|
return rewards;
|
||||||
}
|
}
|
||||||
|
const amountByMatch = new Map<number, number>();
|
||||||
|
for (const [key, betAmount] of detailRows.value) {
|
||||||
|
const matched = matchCount(key);
|
||||||
|
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
|
||||||
|
}
|
||||||
|
if (selectCount === 1 || info.value?.isExclusive) {
|
||||||
|
rewards[selectCount] = totalAmount.value;
|
||||||
|
return rewards;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingReward = totalAmount.value;
|
||||||
|
let accumulatedReward = 0;
|
||||||
|
let nextReward = totalAmount.value;
|
||||||
|
for (let matched = selectCount; matched > 0; matched -= 1) {
|
||||||
|
nextReward /= 2;
|
||||||
|
accumulatedReward += nextReward;
|
||||||
|
if (!amountByMatch.has(matched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rewards[matched] = accumulatedReward;
|
||||||
|
remainingReward -= accumulatedReward;
|
||||||
|
accumulatedReward = 0;
|
||||||
|
}
|
||||||
|
for (let matched = selectCount; matched >= 0; matched -= 1) {
|
||||||
|
if (!amountByMatch.has(matched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rewards[matched] += remainingReward;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return rewards;
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedReward = (key: string): number => {
|
||||||
if (!info.value?.finished) {
|
if (!info.value?.finished) {
|
||||||
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
||||||
return (reward / betAmount).toFixed(1);
|
|
||||||
}
|
}
|
||||||
const matched = matchCount(key);
|
return rewardByMatch.value[matchCount(key)] ?? 0;
|
||||||
const matchedAmount = detailRows.value
|
};
|
||||||
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
|
|
||||||
.reduce((sum, [, value]) => sum + value, 0);
|
const rewardDivisor = (key: string, betAmount: number): number =>
|
||||||
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
|
info.value?.finished
|
||||||
|
? detailRows.value
|
||||||
|
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
|
||||||
|
.reduce((sum, [, value]) => sum + value, 0)
|
||||||
|
: betAmount;
|
||||||
|
|
||||||
|
const expectedMultiplier = (key: string, betAmount: number): string => {
|
||||||
|
const divisor = rewardDivisor(key, betAmount);
|
||||||
|
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const myExpectedReward = (key: string, betAmount: number): string => {
|
||||||
|
const myAmount = myBetMap.value.get(key);
|
||||||
|
if (myAmount === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const divisor = rewardDivisor(key, betAmount);
|
||||||
|
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
|
||||||
|
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadList = async () => {
|
const loadList = async () => {
|
||||||
@@ -295,7 +338,7 @@ onMounted(() => {
|
|||||||
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
||||||
<header class="legacy-top-bar">
|
<header class="legacy-top-bar">
|
||||||
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
||||||
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
|
<div></div>
|
||||||
<h1>국가 베팅장</h1>
|
<h1>국가 베팅장</h1>
|
||||||
<div></div>
|
<div></div>
|
||||||
<div></div>
|
<div></div>
|
||||||
@@ -309,32 +352,37 @@ onMounted(() => {
|
|||||||
{{ info.name }}
|
{{ info.name }}
|
||||||
<span v-if="info.finished">(종료)</span>
|
<span v-if="info.finished">(종료)</span>
|
||||||
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
||||||
({{ parseYearMonth(info.closeYearMonth)[0] }}년
|
({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
||||||
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>(베팅 마감)</span>
|
<span v-else>(베팅 마감)</span>
|
||||||
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="betting-candidates">
|
<div class="betting-candidates">
|
||||||
<button
|
<div
|
||||||
v-for="(candidate, index) in candidates"
|
v-for="(candidate, index) in candidates"
|
||||||
:key="`${info.id}-${index}`"
|
:key="`${info.id}-${index}`"
|
||||||
type="button"
|
class="betting-candidate-cell"
|
||||||
class="betting-candidate"
|
|
||||||
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
|
|
||||||
:disabled="!isDetailOpen"
|
|
||||||
@click="toggleCandidate(index)"
|
|
||||||
>
|
>
|
||||||
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
<button
|
||||||
<span class="candidate-info">
|
type="button"
|
||||||
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
class="betting-candidate"
|
||||||
</span>
|
:class="{
|
||||||
<span class="candidate-rate">
|
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
|
||||||
선택율:
|
}"
|
||||||
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
:disabled="!isDetailOpen"
|
||||||
</span>
|
@click="toggleCandidate(index)"
|
||||||
</button>
|
>
|
||||||
|
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
||||||
|
<span class="candidate-info">
|
||||||
|
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="candidate-rate">
|
||||||
|
선택율:
|
||||||
|
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
||||||
@@ -361,7 +409,7 @@ onMounted(() => {
|
|||||||
{{ selectionLabel(key) }}
|
{{ selectionLabel(key) }}
|
||||||
</div>
|
</div>
|
||||||
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
|
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
|
||||||
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
|
<div>{{ myExpectedReward(key, betAmount) }}</div>
|
||||||
<div>{{ expectedMultiplier(key, betAmount) }}배</div>
|
<div>{{ expectedMultiplier(key, betAmount) }}배</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,8 +430,7 @@ onMounted(() => {
|
|||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
<span v-if="item.finished">(종료)</span>
|
<span v-if="item.finished">(종료)</span>
|
||||||
<span v-else-if="isListOpen(item)">
|
<span v-else-if="isListOpen(item)">
|
||||||
({{ parseYearMonth(item.closeYearMonth)[0] }}년
|
({{ parseYearMonth(item.closeYearMonth)[0] }}년 {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
||||||
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>(베팅 마감)</span>
|
<span v-else>(베팅 마감)</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -400,12 +447,11 @@ onMounted(() => {
|
|||||||
.nation-betting-page {
|
.nation-betting-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 500px;
|
width: 500px;
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.3;
|
line-height: 1.5;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,19 +504,32 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
min-height: 22px;
|
min-height: 21px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 22px;
|
line-height: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidates {
|
.betting-candidates {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
margin-top: -3.5px;
|
||||||
padding: 4px;
|
margin-right: -1.75px;
|
||||||
|
margin-left: -1.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betting-candidate-cell {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 33.33333333%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-right: 1.75px;
|
||||||
|
padding-left: 1.75px;
|
||||||
|
margin-top: 3.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidate {
|
.betting-candidate {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 143px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
@@ -530,7 +589,7 @@ onMounted(() => {
|
|||||||
.betting-form input {
|
.betting-form input {
|
||||||
grid-column: span 4;
|
grid-column: span 4;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 30px;
|
height: 35.5px;
|
||||||
border: 1px solid #777;
|
border: 1px solid #777;
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
color: #303030;
|
color: #303030;
|
||||||
@@ -538,10 +597,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-form button {
|
.betting-form button {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
|
height: 35.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-table {
|
.payout-table {
|
||||||
margin-top: 6px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-row {
|
.payout-row {
|
||||||
@@ -551,7 +611,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.payout-row > div {
|
.payout-row > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 2px 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payout-row > div:not(:first-child) {
|
.payout-row > div:not(:first-child) {
|
||||||
@@ -572,7 +632,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-item {
|
.betting-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: auto;
|
||||||
margin: 0.25em;
|
margin: 0.25em;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -595,6 +655,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.betting-footer .legacy-nav-button {
|
.betting-footer .legacy-nav-button {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
|
height: 35.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-notice,
|
.betting-notice,
|
||||||
@@ -602,6 +663,15 @@ onMounted(() => {
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.betting-notice {
|
||||||
|
position: fixed;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
width: min(320px, calc(100vw - 16px));
|
||||||
|
background: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
.betting-notice.error {
|
.betting-notice.error {
|
||||||
border: 1px solid #9b4848;
|
border: 1px solid #9b4848;
|
||||||
color: #ffd0d0;
|
color: #ffd0d0;
|
||||||
@@ -617,8 +687,9 @@ onMounted(() => {
|
|||||||
width: 1000px;
|
width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-candidates {
|
.betting-candidate-cell {
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
|
||||||
|
width: 16.66666667%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betting-form {
|
.betting-form {
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||||
|
const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL;
|
||||||
|
const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1';
|
||||||
|
const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE;
|
||||||
|
const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting');
|
||||||
|
|
||||||
|
if (!staticBaseUrl && !passwordFile) {
|
||||||
|
throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : '';
|
||||||
|
|
||||||
|
const bettingList = {
|
||||||
|
result: true,
|
||||||
|
bettingList: {
|
||||||
|
7: {
|
||||||
|
id: 7,
|
||||||
|
type: 'bettingNation',
|
||||||
|
name: '천통국 예상',
|
||||||
|
finished: false,
|
||||||
|
selectCnt: 2,
|
||||||
|
isExclusive: false,
|
||||||
|
reqInheritancePoint: true,
|
||||||
|
openYearMonth: 2316,
|
||||||
|
closeYearMonth: 2340,
|
||||||
|
winner: null,
|
||||||
|
totalAmount: 800,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
year: 193,
|
||||||
|
month: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bettingDetail = {
|
||||||
|
result: true,
|
||||||
|
bettingInfo: {
|
||||||
|
id: 7,
|
||||||
|
type: 'bettingNation',
|
||||||
|
name: '천통국 예상',
|
||||||
|
finished: false,
|
||||||
|
selectCnt: 2,
|
||||||
|
isExclusive: false,
|
||||||
|
reqInheritancePoint: true,
|
||||||
|
openYearMonth: 2316,
|
||||||
|
closeYearMonth: 2340,
|
||||||
|
candidates: [
|
||||||
|
{ title: '촉', info: '국력: 1200<br>장수 수: 8<br>도시 수: 5', isHtml: true },
|
||||||
|
{ title: '위', info: '국력: 1100<br>장수 수: 7<br>도시 수: 4', isHtml: true },
|
||||||
|
{ title: '오', info: '국력: 900<br>장수 수: 6<br>도시 수: 3', isHtml: true },
|
||||||
|
{ title: '연', info: '국력: 700<br>장수 수: 5<br>도시 수: 2', isHtml: true },
|
||||||
|
{ title: '양', info: '국력: 650<br>장수 수: 4<br>도시 수: 2', isHtml: true },
|
||||||
|
{ title: '형', info: '국력: 600<br>장수 수: 3<br>도시 수: 1', isHtml: true },
|
||||||
|
],
|
||||||
|
winner: null,
|
||||||
|
},
|
||||||
|
bettingDetail: [
|
||||||
|
['[-1]', 500],
|
||||||
|
['[0,1]', 200],
|
||||||
|
['[1,2]', 100],
|
||||||
|
],
|
||||||
|
myBetting: [['[0,1]', 50]],
|
||||||
|
remainPoint: 1200,
|
||||||
|
year: 193,
|
||||||
|
month: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (context, page) => {
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
// The reference entrance polls install status and can keep the PHP session
|
||||||
|
// occupied. Leave it before the login request so the session lock is free.
|
||||||
|
await page.goto('about:blank');
|
||||||
|
await context.clearCookies();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok() || result.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installBettingFixture = async (page) => {
|
||||||
|
await page.route('**/api.php*', async (route) => {
|
||||||
|
const path = new URL(route.request().url()).searchParams.get('path');
|
||||||
|
if (path === 'Betting/GetBettingList') {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === 'Betting/GetBettingDetail') {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(bettingDetail),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountStaticReference = async (page) => {
|
||||||
|
const hweUrl = new URL('hwe/', staticBaseUrl);
|
||||||
|
const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl);
|
||||||
|
await page.setContent(
|
||||||
|
`<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=500">
|
||||||
|
<base href="${hweUrl}">
|
||||||
|
<link rel="stylesheet" href="${new URL('d_shared/common.css', hweUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('vendors.css', assetUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('common_ts.css', assetUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('bootstrap.css', assetUrl)}">
|
||||||
|
<link rel="stylesheet" href="${new URL('v_nationBetting.css', assetUrl)}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="${new URL('d_shared/common_path.js', hweUrl)}"></script>
|
||||||
|
<script src="${new URL('vendors.js', assetUrl)}"></script>
|
||||||
|
<script src="${new URL('common_ts.js', assetUrl)}"></script>
|
||||||
|
<script src="${new URL('bootstrap.js', assetUrl)}"></script>
|
||||||
|
<script src="${new URL('v_nationBetting.js', assetUrl)}"></script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ waitUntil: 'networkidle' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = async (browser, viewport) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: viewport.width, height: viewport.height },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await installBettingFixture(page);
|
||||||
|
if (staticBaseUrl) {
|
||||||
|
await mountStaticReference(page);
|
||||||
|
} else {
|
||||||
|
await login(context, page);
|
||||||
|
await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.locator('.bettingItem').click();
|
||||||
|
await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
const geometry = await page.locator('#container').evaluate((container) => {
|
||||||
|
const rect = (element) => {
|
||||||
|
const value = element.getBoundingClientRect();
|
||||||
|
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||||
|
};
|
||||||
|
const cards = Array.from(container.querySelectorAll('.bettingCandidate'));
|
||||||
|
const firstCard = cards[0];
|
||||||
|
const cardStyle = getComputedStyle(firstCard);
|
||||||
|
const titleStyle = getComputedStyle(firstCard.querySelector('.title'));
|
||||||
|
const optionalRect = (selector) => {
|
||||||
|
const element = container.querySelector(selector);
|
||||||
|
return element ? rect(element) : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
container: rect(container),
|
||||||
|
topBar: rect(container.querySelector('.back_bar')),
|
||||||
|
candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect),
|
||||||
|
candidates: cards.map(rect),
|
||||||
|
bettingForm: optionalRect('.bettingCandidates + .row'),
|
||||||
|
payoutTable: optionalRect('.bettingCandidates + .row + div'),
|
||||||
|
bettingList: optionalRect('.bettingList'),
|
||||||
|
bottomBar: optionalRect('.bottom_bar, .bg0[style]'),
|
||||||
|
cardStyle: {
|
||||||
|
borderWidth: cardStyle.borderWidth,
|
||||||
|
borderRadius: cardStyle.borderRadius,
|
||||||
|
cursor: cardStyle.cursor,
|
||||||
|
fontSize: cardStyle.fontSize,
|
||||||
|
lineHeight: cardStyle.lineHeight,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
fontWeight: titleStyle.fontWeight,
|
||||||
|
textAlign: titleStyle.textAlign,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('.bettingCandidate').first().click();
|
||||||
|
const pickedStyle = await page
|
||||||
|
.locator('.bettingCandidate')
|
||||||
|
.first()
|
||||||
|
.evaluate((candidate) => {
|
||||||
|
const style = getComputedStyle(candidate);
|
||||||
|
return {
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
outlineWidth: style.outlineWidth,
|
||||||
|
titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`);
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' });
|
||||||
|
return { geometry, pickedStyle, screenshotPath };
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const result = {};
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1280, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
result[viewport.name] = await measure(browser, viewport);
|
||||||
|
}
|
||||||
|
const outputPath = resolve(artifactRoot, 'computed-dom.json');
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -4,10 +4,7 @@ import { dirname, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const imageRoots = [
|
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||||
resolve(repositoryRoot, '../image/game'),
|
|
||||||
resolve(repositoryRoot, '../../image/game'),
|
|
||||||
];
|
|
||||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
@@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
|||||||
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
|
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
|
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
|
||||||
|
const detail = container.querySelector<HTMLElement>('.betting-detail')!;
|
||||||
|
const detailRect = detail.getBoundingClientRect();
|
||||||
|
const candidateRowElement = container.querySelector<HTMLElement>('.betting-candidates')!;
|
||||||
|
const candidateRow = candidateRowElement.getBoundingClientRect();
|
||||||
|
const candidateCells = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate-cell'));
|
||||||
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
|
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
|
||||||
const cardStyle = getComputedStyle(cards[0]!);
|
const cardStyle = getComputedStyle(cards[0]!);
|
||||||
|
const optionalRect = (selector: string) => {
|
||||||
|
const element = container.querySelector<HTMLElement>(selector);
|
||||||
|
if (!element) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
container: { x: containerRect.x, width: containerRect.width },
|
container: { x: containerRect.x, width: containerRect.width, height: containerRect.height },
|
||||||
bar: { width: bar.width, height: bar.height },
|
bar: { width: bar.width, height: bar.height },
|
||||||
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
|
detail: { x: detailRect.x, width: detailRect.width },
|
||||||
|
candidateRow: {
|
||||||
|
x: candidateRow.x,
|
||||||
|
width: candidateRow.width,
|
||||||
|
},
|
||||||
|
candidateCells: candidateCells.map((cell) => {
|
||||||
|
const rect = cell.getBoundingClientRect();
|
||||||
|
return { x: rect.x, width: rect.width };
|
||||||
|
}),
|
||||||
|
cards: cards.map((card) => {
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
}),
|
||||||
|
bettingForm: optionalRect('.betting-form'),
|
||||||
|
payoutTable: optionalRect('.payout-table'),
|
||||||
|
bettingList: optionalRect('.betting-list'),
|
||||||
|
bottomBar: optionalRect('.betting-footer'),
|
||||||
cardStyle: {
|
cardStyle: {
|
||||||
borderWidth: cardStyle.borderWidth,
|
borderWidth: cardStyle.borderWidth,
|
||||||
borderRadius: cardStyle.borderRadius,
|
borderRadius: cardStyle.borderRadius,
|
||||||
@@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 });
|
||||||
expect(geometry.container).toEqual({ x: 140, width: 1000 });
|
|
||||||
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
|
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
|
||||||
expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true);
|
expect(geometry.detail).toEqual({ x: 140, width: 1000 });
|
||||||
|
expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 });
|
||||||
|
expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25));
|
||||||
|
expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75));
|
||||||
|
expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143));
|
||||||
|
expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53));
|
||||||
|
expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 });
|
||||||
|
expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 });
|
||||||
|
expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 });
|
||||||
|
expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 });
|
||||||
expect(geometry.cardStyle).toEqual({
|
expect(geometry.cardStyle).toEqual({
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderRadius: '7px',
|
borderRadius: '7px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: '18.2px',
|
lineHeight: '21px',
|
||||||
});
|
});
|
||||||
|
await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText(
|
||||||
|
'(50 -> 100.0)'
|
||||||
|
);
|
||||||
|
|
||||||
await page.locator('.betting-candidate').nth(0).click();
|
await page.locator('.betting-candidate').nth(0).click();
|
||||||
await page.locator('.betting-candidate').nth(1).click();
|
await page.locator('.betting-candidate').nth(1).click();
|
||||||
const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => {
|
const pickedStyle = await page
|
||||||
const style = getComputedStyle(candidate);
|
.locator('.betting-candidate')
|
||||||
return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight };
|
.first()
|
||||||
});
|
.evaluate((candidate) => {
|
||||||
|
const style = getComputedStyle(candidate);
|
||||||
|
return {
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
outlineWidth: style.outlineWidth,
|
||||||
|
titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
|
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
|
||||||
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
||||||
expect(pickedStyle.outlineWidth).toBe('1px');
|
expect(pickedStyle.outlineWidth).toBe('1px');
|
||||||
@@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
|||||||
return {
|
return {
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
|
firstX: cards[0]!.getBoundingClientRect().x,
|
||||||
firstWidth: cards[0]!.getBoundingClientRect().width,
|
firstWidth: cards[0]!.getBoundingClientRect().width,
|
||||||
fourthY: cards[3]!.getBoundingClientRect().y,
|
fourthY: cards[3]!.getBoundingClientRect().y,
|
||||||
firstY: cards[0]!.getBoundingClientRect().y,
|
firstY: cards[0]!.getBoundingClientRect().y,
|
||||||
@@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
|||||||
});
|
});
|
||||||
expect(geometry.x).toBe(0);
|
expect(geometry.x).toBe(0);
|
||||||
expect(geometry.width).toBe(500);
|
expect(geometry.width).toBe(500);
|
||||||
expect(geometry.firstWidth).toBeCloseTo(161.328125, 3);
|
expect(geometry.firstX).toBe(0);
|
||||||
|
expect(geometry.firstWidth).toBe(164.328125);
|
||||||
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
|
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
|
||||||
|
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
@@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
|||||||
expect(geometry.tableWidth).toBe(1000);
|
expect(geometry.tableWidth).toBe(1000);
|
||||||
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
||||||
expect(geometry.headerWidths).toEqual([
|
expect(geometry.headerWidths).toEqual([
|
||||||
104.609375,
|
104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80,
|
||||||
104.609375,
|
|
||||||
69.734375,
|
|
||||||
121.015625,
|
|
||||||
69.734375,
|
|
||||||
90.25,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
69.734375,
|
|
||||||
80,
|
|
||||||
80.109375,
|
80.109375,
|
||||||
]);
|
]);
|
||||||
expect(geometry.headerStyle).toEqual({
|
expect(geometry.headerStyle).toEqual({
|
||||||
@@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
|||||||
lineHeight: '18.2px',
|
lineHeight: '18.2px',
|
||||||
});
|
});
|
||||||
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
|
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
|
||||||
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
|
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS(
|
||||||
|
'color',
|
||||||
|
'rgb(135, 206, 235)'
|
||||||
|
);
|
||||||
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
|
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
|
||||||
await personality.hover();
|
await personality.hover();
|
||||||
await expect(personality.getByRole('tooltip')).toBeVisible();
|
await expect(personality.getByRole('tooltip')).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user