Implement legacy-compatible survey and unique rewards

This commit is contained in:
2026-07-26 04:17:56 +00:00
parent 9b6d5288d3
commit c2a3b5b797
14 changed files with 1408 additions and 792 deletions
+37 -18
View File
@@ -5,6 +5,7 @@ import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
@@ -22,7 +23,12 @@ const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true;
}
return roles.some((role) => role === 'admin.survey' || role === `admin.survey:${profileName}`);
return roles.some(
(role) =>
role === 'admin.survey.open' ||
role === 'admin.survey.open:*' ||
role === `admin.survey.open:${profileName}`
);
};
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
@@ -66,9 +72,7 @@ const normalizeCode = (value: string | null | undefined): string | null => {
};
const normalizeOptions = (options: string[]): string[] =>
options
.map((option) => option.trim())
.filter((option) => option.length > 0);
options.map((option) => option.trim()).filter((option) => option.length > 0);
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
@@ -225,9 +229,7 @@ export const voteRouter = router({
const pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false);
const userId = ctx.auth?.user.id;
const general = userId
? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } })
: null;
const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null;
const [comments, userCnt, myVoteRow] = await Promise.all([
ctx.db.$queryRaw<VoteCommentRow[]>(GamePrisma.sql`
@@ -257,9 +259,8 @@ export const voteRouter = router({
const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null;
const canReveal = row.reveal_mode === 'after_vote'
? Boolean(myVote) || pollEnded
: pollEnded;
// 레거시는 투표 전에도 현재 집계를 보여준다. after_end만 명시적으로 숨긴다.
const canReveal = row.reveal_mode === 'after_end' ? pollEnded : true;
const voteResults = canReveal
? await ctx.db.$queryRaw<VoteResultRow[]>(GamePrisma.sql`
@@ -355,6 +356,9 @@ export const voteRouter = router({
}
const sortedSelection = [...selection].sort((a, b) => a - b);
if (new Set(sortedSelection).size !== sortedSelection.length) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' });
}
const general = await getMyGeneral(ctx);
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
@@ -394,14 +398,24 @@ export const voteRouter = router({
const itemRegistry = await getItemRegistry();
const uniqueConfig = resolveUniqueConfig(constValues);
const generalRows = await ctx.db.general.findMany({
select: {
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
});
const [generalRows, reservedUniqueRows] = await Promise.all([
ctx.db.general.findMany({
select: {
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
}),
ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
}),
]);
const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({
horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode),
@@ -410,6 +424,11 @@ export const voteRouter = router({
}));
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry);
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
reservedUniqueRows.map((row) => row.targetCode),
itemRegistry
);
const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const rngSeed = buildVoteUniqueSeed(
+292
View File
@@ -0,0 +1,292 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GamePrisma, RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const poll = {
id: 1,
title: '선호하는 병종',
body: '',
options: ['보병', '기병'],
multiple_options: 1,
reveal_mode: 'after_vote',
opener_general_id: 1,
opener_name: '관리자',
start_at: new Date('2026-07-26T00:00:00Z'),
end_at: null,
closed_at: null,
};
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-07-26T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-07-26T00:00:00Z'),
updatedAt: new Date('2026-07-26T00:00:00Z'),
...overrides,
});
const buildAuth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles,
},
sanctions: {},
});
const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' ');
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
myVote?: number[] | null;
voteRows?: Array<{ selection: number[]; cnt: number }>;
pollRow?: typeof poll;
configConst?: Record<string, unknown>;
auctionTargets?: string[];
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const requestCommand = vi.fn(async () => ({
type: 'voteReward' as const,
ok: true as const,
voteId: 1,
generalId: general?.id ?? 0,
awardedUnique: false,
}));
const queryRaw = vi.fn(async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) {
return [options.pollRow ?? poll];
}
if (text.includes('INSERT INTO vote (')) {
return [{ id: 11 }];
}
if (text.includes('FROM vote_comment')) {
return [];
}
if (text.includes('SELECT selection') && text.includes('general_id')) {
return options.myVote ? [{ selection: options.myVote }] : [];
}
if (text.includes('GROUP BY selection')) {
return options.voteRows ?? [{ selection: [0], cnt: 2 }];
}
if (text.includes('INSERT INTO vote_comment')) {
return [];
}
return [];
});
const db = {
$queryRaw: queryRaw,
worldState: {
findFirst: vi.fn(async () => ({
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 3600,
config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } },
meta: {
hiddenSeed: 'seed',
scenarioId: 200,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
},
updatedAt: new Date(),
})),
},
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
general?.userId === where.userId ? general : null
),
findMany: vi.fn(async () => [
{
horseCode: general?.horseCode ?? 'None',
weaponCode: general?.weaponCode ?? 'None',
bookCode: general?.bookCode ?? 'None',
itemCode: general?.itemCode ?? 'None',
},
]),
count: vi.fn(async () => 2),
},
nation: {
findFirst: vi.fn(async () => ({ name: '촉' })),
},
auction: {
findMany: vi.fn(async () => (options.auctionTargets ?? []).map((targetCode) => ({ targetCode }))),
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, queryRaw, db };
};
describe('vote router actor and permission boundaries', () => {
it('rejects unauthenticated survey access', async () => {
const fixture = buildContext({ auth: null });
await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
const fixture = buildContext({ general: owned });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'voteReward',
voteId: 1,
generalId: 7,
goldReward: 90,
})
);
});
it('includes active unique auctions in the API-side reward expectation', async () => {
const fixture = buildContext({
configConst: {
allItems: { weapon: { che_무기_12_칠성검: 1 } },
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
},
auctionTargets: ['che_무기_12_칠성검'],
});
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
unique: { expected: false, itemKey: null },
})
);
});
it('rejects voting and comments when the authenticated user owns no general', async () => {
const fixture = buildContext({ auth: buildAuth([], 'user-2'), general: buildGeneral({ userId: 'user-1' }) });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.vote.submitVote({ voteId: 1, selection: [0] })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
await expect(caller.vote.addComment({ voteId: 1, text: '댓글' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('rejects duplicate selections before persisting a vote', async () => {
const fixture = buildContext({ pollRow: { ...poll, multiple_options: 2 } });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0, 0] })
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '선택한 항목이 올바르지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('shows legacy-compatible aggregate results before the current general votes', async () => {
const fixture = buildContext({ myVote: null, voteRows: [{ selection: [0], cnt: 2 }] });
const result = await appRouter.createCaller(fixture.context).vote.getVoteDetail({ voteId: 1 });
expect(result.myVote).toBeNull();
expect(result.votes).toEqual([{ selection: [0], count: 2 }]);
});
it.each([
['global survey permission', ['admin.survey.open'], true],
['wildcard survey permission', ['admin.survey.open:*'], true],
['matching profile permission', ['admin.survey.open:che:default'], true],
['different profile permission', ['admin.survey.open:hwe:default'], false],
['ordinary user', ['user'], false],
])('%s controls the administrator panel', async (_label, roles, allowed) => {
const fixture = buildContext({ auth: buildAuth(roles) });
const request = appRouter.createCaller(fixture.context).vote.getAdminStatus();
if (allowed) {
await expect(request).resolves.toEqual({ ok: true });
} else {
await expect(request).rejects.toMatchObject({ code: 'FORBIDDEN' });
}
});
});
@@ -24,6 +24,7 @@ import {
evaluateConstraints,
resolveGeneralAction,
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildGenericUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
@@ -382,6 +383,7 @@ const buildUniqueLotteryRunner = (options: {
seedBase: string;
itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
}): UniqueLotteryRunner => {
if (!options.worldView) {
return () => null;
@@ -408,6 +410,11 @@ const buildUniqueLotteryRunner = (options: {
entry.id === general.id ? general.role.items : entry.role.items
);
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry);
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
options.getAdditionalOccupiedUniqueItemKeys?.() ?? [],
options.itemRegistry
);
const rngSeed = buildGenericUniqueSeed(
options.seedBase,
world.currentYear,
@@ -723,6 +730,7 @@ export const createReservedTurnHandler = async (options: {
commandProfile?: TurnCommandProfile;
commandEnv?: TurnCommandEnv;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -944,6 +952,7 @@ export const createReservedTurnHandler = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
});
let baseContext: ActionContextBase = {
general: currentGeneral,
+15
View File
@@ -473,6 +473,8 @@ const createTurnDaemonRuntimeWithLease = async (
neutralAuctionRegistrar.handler,
frontStateHandler
);
let occupiedAuctionUniqueItemKeys: string[] = [];
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler:
@@ -486,6 +488,7 @@ const createTurnDaemonRuntimeWithLease = async (
getWorld: () => worldRef,
commandProfile,
commandEnv: monthlyCommandEnv,
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
})),
calendarHandler: calendarHandler ?? undefined,
autoAdvanceDiplomacyMonth: false,
@@ -501,6 +504,7 @@ const createTurnDaemonRuntimeWithLease = async (
? async (general) => {
const promises: Promise<unknown>[] = [];
promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id));
promises.push(refreshOccupiedAuctionUniqueItemKeys());
if (general.nationId > 0 && general.officerLevel >= 5) {
promises.push(
reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel)
@@ -648,6 +652,17 @@ const createTurnDaemonRuntimeWithLease = async (
if (commandConnector && databaseCommandQueue) {
await commandConnector.connect();
await databaseCommandQueue.initialize();
refreshOccupiedAuctionUniqueItemKeys = async () => {
const rows = await commandConnector.prisma.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
occupiedAuctionUniqueItemKeys = rows.flatMap((row) => (row.targetCode ? [row.targetCode] : []));
};
}
const baseClose = close;
@@ -11,6 +11,7 @@ import {
LogFormat,
LogScope,
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
@@ -1162,6 +1163,21 @@ async function handleVoteReward(
generals.map((entry) => entry.role.items),
itemRegistry
);
if (ctx.commandDb) {
const reservedUniqueRows = await ctx.commandDb.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
reservedUniqueRows.map((row) => row.targetCode),
itemRegistry
);
}
const userCount = generals.filter((entry) => entry.npcState < 2).length;
const rngSeed = buildVoteUniqueSeed(
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
@@ -173,4 +173,138 @@ describe('unique lottery on general commands', () => {
const logTexts = (result.logs ?? []).map((entry) => entry.text);
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true);
});
it('does not award a unique item reserved by an active auction', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
{
id: 1,
name: 'City_1',
nationId: 1,
viewName: 'City_1',
agriculture: 100,
agricultureMax: 2000,
commerce: 100,
commerceMax: 2000,
security: 100,
securityMax: 100,
def: 100,
defMax: 100,
wall: 100,
wallMax: 100,
pop: 10000,
popMax: 50000,
trust: 50,
supplyState: 1,
frontState: 0,
tradepoint: 0,
level: 1,
meta: {},
},
] as any,
nations: [
{
id: 1,
name: 'TestNation',
color: '#FF0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 10000,
rice: 10000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [
{
id: 1,
name: 'City_1',
level: 1,
region: 1,
position: { x: 0, y: 0 },
connections: [],
max: {} as any,
initial: {} as any,
},
],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
} as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
allItems: {
weapon: {
che_무기_12_칠성검: 1,
},
},
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
scenarioMeta: {
startYear: 180,
} as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 3600,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
meta: {
hiddenSeed: 'seed',
scenarioId: 200,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const reservedTurns = new InMemoryReservedTurnStore(
{
generalTurn: { findMany: async () => [] },
nationTurn: { findMany: async () => [] },
} as any,
{ maxGeneralTurns: 30, maxNationTurns: 12 }
);
reservedTurns.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} };
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map: snapshot.map,
unitSet: snapshot.unitSet,
getWorld: () => world,
getAdditionalOccupiedUniqueItemKeys: () => ['che_무기_12_칠성검'],
});
const result = handler.execute({
general: world.getGeneralById(1)!,
city: world.getCityById(1)!,
nation: world.getNationById(1)!,
world: world.getState(),
schedule,
});
expect(result.general?.role.items.weapon).toBeNull();
expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false);
});
});
+76
View File
@@ -223,4 +223,80 @@ describe('voteReward command', () => {
const afterSecond = world.getGeneralById(1);
expect(afterSecond?.gold).toBe(1500);
});
it('treats an active unique auction as occupied when revalidating the lottery', async () => {
const general = buildGeneral(1);
const snapshot: TurnWorldSnapshot = {
generals: [general] as any,
cities: [] as any,
nations: [] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
} as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
allItems: { weapon: { che_무기_12_칠성검: 1 } },
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 180 } as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 3600,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
meta: {
hiddenSeed: 'seed',
scenarioId: 200,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const handler = createTurnDaemonCommandHandler({ world });
const commandDb = {
auction: {
findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }],
},
};
const result = await handler.handle(
{
type: 'voteReward',
voteId: 1,
generalId: 1,
goldReward: 500,
unique: { expected: false, itemKey: null },
},
{ db: commandDb as any }
);
expect(result).toMatchObject({
type: 'voteReward',
ok: true,
awardedUnique: false,
});
expect(world.getGeneralById(1)?.gold).toBe(1500);
expect(world.getGeneralById(1)?.role.items.weapon).toBeNull();
});
});
+579 -762
View File
@@ -1,865 +1,682 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>;
type VoteDetailResponse = Awaited<ReturnType<typeof trpc.vote.getVoteDetail.query>>;
type RevealMode = 'after_vote' | 'after_end';
type VoteDetail = Awaited<ReturnType<typeof trpc.vote.getVoteDetail.query>>;
type PollSummary = VoteListResponse['polls'][number];
type VoteDetail = VoteDetailResponse;
type VoteResultEntry = VoteDetail['votes'][number];
const loading = ref(false);
const detailLoading = ref(false);
const error = ref<string | null>(null);
const polls = ref<PollSummary[]>([]);
const voteReward = ref(0);
const activeVoteId = ref<number | null>(null);
const voteDetail = ref<VoteDetail | null>(null);
const adminEnabled = ref(false);
const currentVoteId = ref<number | null>(null);
const currentVote = ref<VoteDetail | null>(null);
const loading = ref(false);
const detailLoading = ref(false);
const isVoteAdmin = ref(false);
const showNewVote = ref(false);
const message = ref('');
const messageKind = ref<'success' | 'error'>('success');
const mySinglePick = ref(0);
const myMultiPick = ref<number[]>([]);
const myComment = ref('');
const newVoteTitle = ref('');
const newVoteOptionsText = ref('');
const newVoteMultipleOptions = ref(1);
const selectionSingle = ref<number | null>(null);
const selectionMulti = ref<number[]>([]);
const commentDraft = ref('');
const actionMessage = ref<string | null>(null);
const newPollTitle = ref('');
const newPollBody = ref('');
const newPollOptionsText = ref('');
const newPollMultipleOptions = ref(1);
const newPollEndAt = ref('');
const newPollRevealMode = ref<RevealMode>('after_vote');
const newPollClosePrevious = ref(true);
const updateTitle = ref('');
const updateBody = ref('');
const updateOptionsText = ref('');
const updateMultipleOptions = ref<number | null>(null);
const updateEndAt = ref('');
const updateRevealMode = ref<RevealMode>('after_vote');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const formatDate = (value: string | null): string => {
if (!value) {
return '-';
const showMessage = (text: string, kind: 'success' | 'error') => {
message.value = text;
messageKind.value = kind;
};
const isEnded = (poll: { endAt: string | null; closedAt: string | null }): boolean => {
if (poll.closedAt) {
return true;
}
return poll.endAt ? new Date(poll.endAt).getTime() < Date.now() : false;
};
const canVote = computed(
() => Boolean(currentVote.value) && !currentVote.value?.myVote && !isEnded(currentVote.value!.voteInfo)
);
const voteTotal = computed(() => (currentVote.value?.votes ?? []).reduce((total, vote) => total + vote.count, 0));
const voteDistribution = computed(() => {
const result = Array.from({ length: currentVote.value?.voteInfo.options.length ?? 0 }, () => 0);
for (const vote of currentVote.value?.votes ?? []) {
for (const selection of vote.selection) {
if (selection >= 0 && selection < result.length) {
result[selection] += vote.count;
}
}
}
return result;
});
const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filter((option) => option.length > 0));
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
const formatStartDate = (value: string): string => value.slice(0, 10);
const formatCommentDate = (value: string): string => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('ko-KR');
const pad = (part: number) => String(part).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const parseOptionsText = (text: string): string[] =>
text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const voteColor = (index: number): string =>
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
const resolveDateInput = (value: string): string | undefined => {
if (!value) {
return undefined;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return undefined;
}
return parsed.toISOString();
};
const isPollEnded = (poll: { endAt: string | null; closedAt: string | null }): boolean => {
if (poll.closedAt) {
return true;
}
if (!poll.endAt) {
return false;
}
const endDate = new Date(poll.endAt);
if (Number.isNaN(endDate.getTime())) {
return false;
}
return endDate <= new Date();
};
const currentPoll = computed(() => {
if (!polls.value.length) {
return null;
}
const selected = polls.value.find((poll) => poll.id === activeVoteId.value);
return selected ?? polls.value[0] ?? null;
});
const revealLabel = computed(() => {
if (!voteDetail.value) {
return '';
}
return voteDetail.value.voteInfo.revealMode === 'after_vote' ? '투표 후 공개' : '종료 후 공개';
});
const pollEnded = computed(() => {
if (!voteDetail.value) {
return false;
}
return isPollEnded({
endAt: voteDetail.value.voteInfo.endAt,
closedAt: voteDetail.value.voteInfo.closedAt,
});
});
const canVote = computed(() => {
if (!voteDetail.value) {
return false;
}
if (voteDetail.value.myVote) {
return false;
}
return !pollEnded.value;
});
const canReveal = computed(() => {
if (!voteDetail.value) {
return false;
}
if (voteDetail.value.voteInfo.revealMode === 'after_vote') {
return Boolean(voteDetail.value.myVote) || pollEnded.value;
}
return pollEnded.value;
});
const isSingleChoice = computed(() => voteDetail.value?.voteInfo.multipleOptions === 1);
const voteDistribution = computed(() => {
if (!voteDetail.value) {
return [] as number[];
}
const optionCount = voteDetail.value.voteInfo.options.length;
const counts = Array.from({ length: optionCount }, () => 0);
for (const entry of voteDetail.value.votes as VoteResultEntry[]) {
for (const index of entry.selection) {
if (index >= 0 && index < counts.length) {
counts[index] += entry.count;
}
}
}
return counts;
});
const voteTotal = computed(() =>
(voteDetail.value?.votes ?? []).reduce((sum, entry) => sum + entry.count, 0)
);
const selectPoll = (pollId: number) => {
if (activeVoteId.value === pollId) {
return;
}
activeVoteId.value = pollId;
};
const loadVoteList = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
const result = await trpc.vote.getVoteList.query();
polls.value = result.polls;
voteReward.value = result.voteReward ?? 0;
if (!activeVoteId.value || !result.polls.some((poll) => poll.id === activeVoteId.value)) {
const openPoll = result.polls.find((poll) => !isPollEnded(poll));
activeVoteId.value = openPoll?.id ?? result.polls[0]?.id ?? null;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const voteColorText = (index: number): string => ([1, 2].includes(index % 7) ? '#000' : '#fff');
const loadVoteDetail = async (voteId: number) => {
if (detailLoading.value) {
return;
}
detailLoading.value = true;
error.value = null;
actionMessage.value = null;
try {
const result = await trpc.vote.getVoteDetail.query({ voteId });
voteDetail.value = result;
if (result.myVote && result.myVote.length > 0) {
if (result.voteInfo.multipleOptions === 1) {
selectionSingle.value = result.myVote[0] ?? null;
selectionMulti.value = [...result.myVote];
} else {
selectionSingle.value = null;
selectionMulti.value = [...result.myVote];
}
} else {
selectionSingle.value = null;
selectionMulti.value = [];
}
commentDraft.value = '';
updateTitle.value = result.voteInfo.title;
updateBody.value = result.voteInfo.body;
updateMultipleOptions.value = result.voteInfo.multipleOptions;
updateRevealMode.value = result.voteInfo.revealMode;
updateEndAt.value = result.voteInfo.endAt ? result.voteInfo.endAt.slice(0, 16) : '';
} catch (err) {
error.value = resolveErrorMessage(err);
const detail = await trpc.vote.getVoteDetail.query({ voteId });
currentVote.value = detail;
currentVoteId.value = voteId;
mySinglePick.value = detail.myVote?.[0] ?? 0;
myMultiPick.value = detail.myVote ? [...detail.myVote] : [];
myComment.value = '';
} catch (error) {
showMessage(getErrorMessage(error), 'error');
} finally {
detailLoading.value = false;
}
};
const refreshAll = async () => {
await loadVoteList();
if (activeVoteId.value) {
await loadVoteDetail(activeVoteId.value);
const reloadVote = async () => {
if (loading.value) {
return;
}
loading.value = true;
message.value = '';
try {
const result = await trpc.vote.getVoteList.query();
polls.value = result.polls;
voteReward.value = result.voteReward;
const nextVoteId =
(currentVoteId.value && result.polls.some((poll) => poll.id === currentVoteId.value)
? currentVoteId.value
: result.polls[0]?.id) ?? null;
if (nextVoteId) {
await loadVoteDetail(nextVoteId);
} else {
currentVoteId.value = null;
currentVote.value = null;
}
} catch (error) {
showMessage(getErrorMessage(error), 'error');
} finally {
loading.value = false;
}
};
const selectVote = (voteId: number) => {
if (voteId !== currentVoteId.value) {
void loadVoteDetail(voteId);
}
};
const changeMultiPick = (index: number, checked: boolean) => {
const limit = currentVote.value?.voteInfo.multipleOptions ?? 0;
if (checked && limit > 0 && myMultiPick.value.length > limit) {
myMultiPick.value = myMultiPick.value.filter((value) => value !== index);
showMessage(`${limit}개까지만 선택할 수 있습니다.`, 'error');
}
};
const submitVote = async () => {
if (!voteDetail.value) {
if (!currentVote.value) {
return;
}
const optionLimit = voteDetail.value.voteInfo.multipleOptions;
const selected = isSingleChoice.value
? selectionSingle.value !== null
? [selectionSingle.value]
: []
: [...selectionMulti.value];
if (selected.length === 0) {
actionMessage.value = '선택한 항목이 없습니다.';
const selection = currentVote.value.voteInfo.multipleOptions === 1 ? [mySinglePick.value] : [...myMultiPick.value];
if (selection.length === 0) {
showMessage('선택한 항목이 없습니다.', 'error');
return;
}
if (optionLimit >= 1 && selected.length > optionLimit) {
actionMessage.value = '선택한 항목이 너무 많습니다.';
return;
}
actionMessage.value = null;
try {
const result = await trpc.vote.submitVote.mutate({
voteId: voteDetail.value.voteInfo.id,
selection: selected,
voteId: currentVote.value.voteInfo.id,
selection,
});
actionMessage.value = result.wonLottery
? '투표 완료! 유니크 추첨에 당첨되었습니다.'
: '투표가 완료되었습니다.';
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
showMessage(result.wonLottery ? '특별한 설문 보상이 제공되었습니다!' : '설문을 마쳤습니다.', 'success');
await loadVoteDetail(currentVote.value.voteInfo.id);
} catch (error) {
showMessage(getErrorMessage(error), 'error');
}
};
const submitComment = async () => {
if (!voteDetail.value) {
if (!currentVote.value || myComment.value.length === 0) {
return;
}
const text = commentDraft.value.trim();
if (!text) {
return;
}
actionMessage.value = null;
try {
await trpc.vote.addComment.mutate({
voteId: voteDetail.value.voteInfo.id,
text,
voteId: currentVote.value.voteInfo.id,
text: myComment.value,
});
commentDraft.value = '';
await loadVoteDetail(voteDetail.value.voteInfo.id);
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
myComment.value = '';
showMessage('댓글을 달았습니다.', 'success');
await loadVoteDetail(currentVote.value.voteInfo.id);
} catch (error) {
showMessage(getErrorMessage(error), 'error');
}
};
const createPoll = async () => {
const options = parseOptionsText(newPollOptionsText.value);
const endAt = resolveDateInput(newPollEndAt.value);
actionMessage.value = null;
const submitNewVote = async () => {
try {
await trpc.vote.createPoll.mutate({
title: newPollTitle.value.trim(),
body: newPollBody.value.trim(),
options,
multipleOptions: newPollMultipleOptions.value,
endAt,
revealMode: newPollRevealMode.value,
closePrevious: newPollClosePrevious.value,
title: newVoteTitle.value,
body: '',
options: newVoteOptions.value,
multipleOptions: newVoteMultipleOptions.value,
revealMode: 'after_vote',
closePrevious: true,
});
newPollTitle.value = '';
newPollBody.value = '';
newPollOptionsText.value = '';
newPollMultipleOptions.value = 1;
newPollEndAt.value = '';
newPollRevealMode.value = 'after_vote';
newPollClosePrevious.value = true;
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
}
};
const updatePoll = async () => {
if (!voteDetail.value) {
return;
}
const appendOptions = parseOptionsText(updateOptionsText.value);
const endAt = resolveDateInput(updateEndAt.value);
actionMessage.value = null;
try {
await trpc.vote.updatePoll.mutate({
voteId: voteDetail.value.voteInfo.id,
title: updateTitle.value.trim() || undefined,
body: updateBody.value.trim() || undefined,
appendOptions: appendOptions.length > 0 ? appendOptions : undefined,
multipleOptions: updateMultipleOptions.value ?? undefined,
endAt,
revealMode: updateRevealMode.value,
});
updateOptionsText.value = '';
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
}
};
const closePoll = async () => {
if (!voteDetail.value) {
return;
}
actionMessage.value = null;
try {
await trpc.vote.closePoll.mutate({ voteId: voteDetail.value.voteInfo.id });
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
showMessage('설문 조사가 생성되었습니다.', 'success');
newVoteTitle.value = '';
newVoteOptionsText.value = '';
newVoteMultipleOptions.value = 1;
showNewVote.value = false;
await reloadVote();
} catch (error) {
showMessage(getErrorMessage(error), 'error');
}
};
onMounted(() => {
void refreshAll();
void trpc.vote.getAdminStatus.query().then((result) => {
adminEnabled.value = Boolean(result?.ok);
}).catch(() => {
adminEnabled.value = false;
});
});
watch(activeVoteId, (voteId) => {
if (voteId) {
void loadVoteDetail(voteId);
}
void reloadVote();
void trpc.vote.getAdminStatus
.query()
.then((result) => {
isVoteAdmin.value = result.ok === true;
})
.catch(() => {
isVoteAdmin.value = false;
});
});
</script>
<template>
<main class="survey-view">
<header class="page-header">
<div>
<h1 class="page-title">설문조사</h1>
<p class="page-subtitle">투표 참여 보상과 유니크 추첨이 함께 진행됩니다.</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인으로</RouterLink>
<button class="ghost" @click="refreshAll" :disabled="loading">새로고침</button>
</div>
<main id="container" class="pageVote bg0">
<header class="back_bar bg0">
<RouterLink class="btn btn-sammo-base2 back_btn" to="/"> 닫기</RouterLink>
<button class="btn btn-sammo-base2 reload_btn" type="button" :disabled="loading" @click="reloadVote">
갱신
</button>
<h2 class="title"></h2>
<div>&nbsp;</div>
<div></div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="actionMessage" class="notice">{{ actionMessage }}</div>
<div
v-if="message"
class="vote-notice"
:class="messageKind"
:role="messageKind === 'error' ? 'alert' : 'status'"
>
{{ message }}
</div>
<div id="vote-title" class="bg2">설문 조사({{ voteReward }}금과 추첨으로 유니크템 증정!)</div>
<section class="survey-grid">
<div class="survey-main">
<PanelCard title="보상 안내">
<div class="reward-block">
<div>투표 참여 보상: <strong>{{ voteReward }}</strong> </div>
<div>추첨 보상: 유니크 장비 1</div>
</div>
</PanelCard>
<div v-if="detailLoading && !currentVote" class="loading">불러오는 중...</div>
<table v-if="currentVote" id="vote-result">
<colgroup>
<col class="vote-idx" />
<col class="vote-count" />
<col class="vote-percent" />
<col class="vote-option" />
</colgroup>
<thead>
<tr>
<th colspan="3" class="text-end bg1">설문 제목</th>
<th id="vote-detail-title">
{{ currentVote.voteInfo.title }}
<template v-if="currentVote.voteInfo.multipleOptions !== 1">
({{
currentVote.voteInfo.multipleOptions === 0
? currentVote.voteInfo.options.length
: currentVote.voteInfo.multipleOptions
}} 선택 가능 )
</template>
</th>
</tr>
<tr>
<th colspan="3" class="text-end bg1">게시자</th>
<th id="vote-detail-opener">{{ currentVote.voteInfo.openerName || '[SYSTEM]' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(option, index) in currentVote.voteInfo.options" :key="index">
<td v-if="canVote" class="text-center">
<input
v-if="currentVote.voteInfo.multipleOptions === 1"
:id="`v-vote-${index}`"
v-model="mySinglePick"
class="form-check-input"
type="radio"
:value="index"
/>
<input
v-else
:id="`v-vote-${index}`"
v-model="myMultiPick"
class="form-check-input"
type="checkbox"
:value="index"
@change="changeMultiPick(index, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td
v-else
class="text-end f_tnum"
:style="{ backgroundColor: voteColor(index), color: voteColorText(index) }"
>
{{ index + 1 }}.
</td>
<td class="text-end f_tnum vote-count">
<label :for="`v-vote-${index}`">{{ voteDistribution[index] }}</label>
</td>
<td class="text-end f_tnum vote-percent">
<label :for="`v-vote-${index}`">
({{ percentage(voteDistribution[index] ?? 0, voteTotal) }}%)
</label>
</td>
<td>
<label :for="`v-vote-${index}`">{{ option }}</label>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<template v-if="canVote">
<td class="text-center">투표</td>
<td colspan="2">
<button class="btn btn-primary vote-submit" @click="submitVote">투표</button>
</td>
</template>
<td v-else colspan="3" class="text-center">결산</td>
<td>
투표율: {{ voteTotal }} / {{ currentVote.userCnt }} ({{
percentage(voteTotal, currentVote.userCnt)
}}%)
</td>
</tr>
</tfoot>
</table>
<PanelCard title="현재 설문" :subtitle="currentPoll?.title ?? '설문 정보 없음'">
<SkeletonLines v-if="loading || detailLoading" :lines="6" />
<div v-else-if="!voteDetail" class="placeholder">설문 정보가 없습니다.</div>
<div v-else class="poll-detail">
<div class="poll-meta">
<div><strong>제목</strong> {{ voteDetail.voteInfo.title }}</div>
<div v-if="voteDetail.voteInfo.body"><strong>본문</strong> {{ voteDetail.voteInfo.body }}</div>
<div><strong>작성자</strong> {{ voteDetail.voteInfo.openerName }}</div>
<div>
<strong>선택 제한</strong>
{{
voteDetail.voteInfo.multipleOptions === 0
? '제한 없음'
: voteDetail.voteInfo.multipleOptions === 1
? '1개 선택'
: `${voteDetail.voteInfo.multipleOptions}개 선택`
}}
</div>
<div><strong>공개 정책</strong> {{ revealLabel }}</div>
<div><strong>시작</strong> {{ formatDate(voteDetail.voteInfo.startAt) }}</div>
<div><strong>종료</strong> {{ formatDate(voteDetail.voteInfo.endAt) }}</div>
<div><strong>닫힘</strong> {{ formatDate(voteDetail.voteInfo.closedAt) }}</div>
</div>
<form v-if="currentVote" @submit.prevent="submitComment">
<table id="vote-comment">
<colgroup>
<col class="comment-idx" />
<col class="comment-name" />
<col class="comment-text" />
<col class="comment-date" />
</colgroup>
<thead>
<tr class="bg1 text-center">
<th>#</th>
<th><span>국가명</span><span>장수명</span></th>
<th>댓글</th>
<th>일시</th>
</tr>
</thead>
<tbody>
<tr v-for="(comment, index) in currentVote.comments" :key="comment.id">
<td class="comment-idx f_tnum">{{ index + 1 }}.</td>
<td class="comment-name">
<span>{{ comment.nationName }}</span
><span>{{ comment.generalName }}</span>
</td>
<td>{{ comment.text }}</td>
<td class="comment-date f_tnum">{{ formatCommentDate(comment.createdAt) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td><button class="btn btn-primary comment-submit" type="submit">댓글 달기</button></td>
<td colspan="2">
<input v-model="myComment" class="form-control" maxlength="200" aria-label="댓글" />
</td>
</tr>
</tfoot>
</table>
</form>
<div class="poll-options">
<div
v-for="(option, idx) in voteDetail.voteInfo.options"
:key="`${voteDetail.voteInfo.id}-${idx}`"
class="poll-option"
>
<label>
<input
v-if="isSingleChoice"
type="radio"
:value="idx"
v-model="selectionSingle"
:disabled="!canVote"
/>
<input
v-else
type="checkbox"
:value="idx"
v-model="selectionMulti"
:disabled="!canVote"
/>
<span>{{ option }}</span>
</label>
</div>
</div>
<div class="poll-actions">
<button class="ghost" @click="submitVote" :disabled="!canVote">투표하기</button>
<span v-if="voteDetail.myVote" class="muted">이미 투표했습니다.</span>
<span v-else-if="pollEnded" class="muted">설문이 종료되었습니다.</span>
</div>
</div>
</PanelCard>
<PanelCard title="결과">
<SkeletonLines v-if="detailLoading" :lines="4" />
<div v-else-if="!voteDetail" class="placeholder">설문 결과가 없습니다.</div>
<div v-else-if="!canReveal" class="placeholder">
결과는 {{ revealLabel }}됩니다.
</div>
<div v-else class="poll-results">
<div class="result-summary">
참여 인원 {{ voteTotal }} / {{ voteDetail.userCnt }}
</div>
<div v-for="(option, idx) in voteDetail.voteInfo.options" :key="`result-${idx}`" class="result-row">
<div class="result-option">{{ option }}</div>
<div class="result-count">{{ voteDistribution[idx] ?? 0 }}</div>
<div class="result-percent">
{{
voteTotal > 0
? ((voteDistribution[idx] ?? 0) / voteTotal * 100).toFixed(1)
: '0.0'
}}%
</div>
</div>
</div>
</PanelCard>
<PanelCard title="댓글">
<SkeletonLines v-if="detailLoading" :lines="4" />
<div v-else-if="!voteDetail" class="placeholder">댓글을 불러오는 중입니다.</div>
<div v-else>
<div v-if="voteDetail.comments.length === 0" class="placeholder">아직 댓글이 없습니다.</div>
<div v-else class="comment-list">
<div v-for="comment in voteDetail.comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<span>{{ comment.nationName }}</span>
<span>{{ comment.generalName }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
<div class="comment-form">
<input
v-model="commentDraft"
type="text"
maxlength="200"
placeholder="댓글을 입력하세요"
/>
<button class="ghost" @click="submitComment">댓글 등록</button>
</div>
</div>
</PanelCard>
<div id="vote-old-title" class="bg2">이전 설문 조사</div>
<div id="vote-old-list">
<div v-for="poll in polls" :key="poll.id" class="vote-old-item">
<a href="#" @click.prevent="selectVote(poll.id)">{{ poll.title }}</a>
({{ formatStartDate(poll.startAt) }})
</div>
</div>
<div class="survey-side">
<PanelCard title="설문 목록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="poll-list">
<div v-if="polls.length === 0" class="placeholder">등록된 설문이 없습니다.</div>
<button
v-for="poll in polls"
:key="poll.id"
class="poll-list-item"
:class="{ active: poll.id === currentPoll?.id }"
@click="selectPoll(poll.id)"
>
<div class="poll-title">{{ poll.title }}</div>
<div class="poll-meta-row">
<span>{{ formatDate(poll.startAt) }}</span>
<span>{{ isPollEnded(poll) ? '종료됨' : '진행중' }}</span>
</div>
</button>
<div v-if="isVoteAdmin" id="vote-new-panel">
<div><a href="#" @click.prevent="showNewVote = !showNewVote"> 설문 조사 열기</a></div>
<template v-if="showNewVote">
<div class="admin-row">
<div>설문 제목</div>
<div><input v-model="newVoteTitle" class="form-control" type="text" /></div>
</div>
<div class="admin-row">
<div>설문 대상(엔터로 구분) ({{ newVoteOptions.length }})</div>
<div>
<textarea
v-model="newVoteOptionsText"
class="form-control"
:rows="newVoteOptions.length + 1"
></textarea>
</div>
</PanelCard>
</div>
<div class="admin-row">
<div>동시 응답 (0=모두)</div>
<div>
<input
v-model.number="newVoteMultipleOptions"
class="form-control"
type="number"
min="0"
:max="newVoteOptions.length"
/>
</div>
</div>
<div class="admin-submit">
<button class="btn btn-primary" type="button" @click="submitNewVote">제출</button>
</div>
</template>
</div>
<PanelCard v-if="adminEnabled" title="관리자 패널">
<div class="admin-section">
<h3> 설문 생성</h3>
<label>
제목
<input v-model="newPollTitle" type="text" />
</label>
<label>
본문
<textarea v-model="newPollBody" rows="3" />
</label>
<label>
항목 (줄바꿈 구분)
<textarea v-model="newPollOptionsText" rows="4" />
</label>
<label>
동시 응답 (0=제한 없음)
<input v-model.number="newPollMultipleOptions" type="number" min="0" />
</label>
<label>
종료 시각
<input v-model="newPollEndAt" type="datetime-local" />
</label>
<label>
공개 정책
<select v-model="newPollRevealMode">
<option value="after_vote">투표 공개</option>
<option value="after_end">종료 공개</option>
</select>
</label>
<label class="checkbox">
<input v-model="newPollClosePrevious" type="checkbox" />
기존 설문 종료
</label>
<button class="ghost" @click="createPoll">설문 생성</button>
</div>
<div class="admin-section" v-if="voteDetail">
<h3>설문 수정</h3>
<p class="muted">응답 0건일 때만 수정 가능합니다.</p>
<label>
제목
<input v-model="updateTitle" type="text" />
</label>
<label>
본문
<textarea v-model="updateBody" rows="3" />
</label>
<label>
항목 추가 (줄바꿈 구분)
<textarea v-model="updateOptionsText" rows="3" />
</label>
<label>
동시 응답
<input v-model.number="updateMultipleOptions" type="number" min="0" />
</label>
<label>
종료 시각
<input v-model="updateEndAt" type="datetime-local" />
</label>
<label>
공개 정책
<select v-model="updateRevealMode">
<option value="after_vote">투표 공개</option>
<option value="after_end">종료 공개</option>
</select>
</label>
<div class="admin-actions">
<button class="ghost" @click="updatePoll">수정 적용</button>
<button class="ghost" @click="closePoll">설문 종료</button>
</div>
</div>
</PanelCard>
</div>
</section>
<footer class="bottom_bar bg0">
<RouterLink class="btn btn-sammo-base2 back_btn" to="/"> 닫기</RouterLink>
</footer>
</main>
</template>
<style scoped>
.survey-view {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.pageVote {
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.5;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
.bg0 {
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
.bg1 {
background-image: url('/image/game/back_green.jpg');
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.bg2 {
background-image: url('/image/game/back_blue.jpg');
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
}
.ghost:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #ff8080;
}
.notice {
color: rgba(232, 221, 196, 0.8);
}
.survey-grid {
.back_bar {
width: 100%;
height: 32px;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.survey-main,
.survey-side {
display: flex;
flex-direction: column;
gap: 16px;
}
.reward-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.poll-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.poll-meta {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.poll-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-option {
display: flex;
gap: 8px;
align-items: center;
}
.poll-actions {
display: flex;
align-items: center;
gap: 12px;
}
.poll-results {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-summary {
font-size: 0.9rem;
color: rgba(232, 221, 196, 0.8);
}
.result-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
font-size: 0.9rem;
}
.result-count,
.result-percent {
text-align: right;
white-space: nowrap;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment-item {
padding: 10px 12px;
border: 1px solid rgba(201, 164, 90, 0.3);
border-radius: 8px;
background: rgba(16, 16, 16, 0.5);
}
.comment-header {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
}
.comment-date {
opacity: 0.7;
}
.comment-text {
margin-top: 6px;
}
.comment-form {
display: flex;
gap: 8px;
margin-top: 12px;
}
.comment-form input {
flex: 1;
padding: 6px 8px;
background: rgba(16, 16, 16, 0.6);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
}
.poll-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-list-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(201, 164, 90, 0.3);
background: rgba(16, 16, 16, 0.5);
text-align: left;
cursor: pointer;
color: inherit;
}
.poll-list-item.active {
border-color: rgba(255, 214, 140, 0.8);
background: rgba(32, 24, 12, 0.8);
}
.poll-meta-row {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.placeholder {
color: rgba(232, 221, 196, 0.7);
}
.muted {
color: rgba(232, 221, 196, 0.6);
}
.admin-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.admin-section h3 {
.back_bar .title {
margin: 0;
font-size: 1rem;
}
.admin-section label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.85rem;
.btn {
min-height: 35.5px;
padding: 5.25px 10.5px;
border: 1px solid transparent;
border-radius: 5.25px;
color: #fff;
font: inherit;
cursor: pointer;
}
.admin-section input,
.admin-section textarea,
.admin-section select {
padding: 6px 8px;
background: rgba(16, 16, 16, 0.6);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
.btn:hover {
filter: brightness(1.15);
}
.admin-section .checkbox {
flex-direction: row;
align-items: center;
gap: 8px;
.btn:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.admin-actions {
display: flex;
gap: 8px;
.btn:active {
transform: translateY(1px);
}
@media (max-width: 1024px) {
.survey-grid {
grid-template-columns: 1fr;
.btn:disabled {
opacity: 0.65;
cursor: default;
}
.btn-sammo-base2 {
height: 32px;
min-height: 32px;
margin-right: 2px;
border-color: #004f28;
background: #00582c;
font-weight: 600;
text-align: center;
text-decoration: none;
}
.back_bar .btn-sammo-base2 {
width: 88px;
}
.btn-primary {
border-color: #0d6efd;
background: #0d6efd;
}
#vote-title {
font-size: 1.8em;
line-height: 1.5;
text-align: center;
}
#vote-old-title {
font-size: 1.5em;
line-height: 1.5;
text-align: center;
}
#vote-result,
#vote-comment {
width: 100%;
border-collapse: collapse;
}
#vote-result th,
#vote-result td,
#vote-comment th,
#vote-comment td {
padding-right: 1ch;
padding-left: 1ch;
}
#vote-result label {
display: block;
}
#vote-result .vote-idx {
width: 5ch;
}
#vote-result .vote-count {
width: 55px;
padding-right: 0;
}
#vote-result .vote-percent {
width: 70px;
padding-left: 0;
}
.vote-submit {
width: 100%;
}
#vote-comment .comment-idx {
width: 5ch;
text-align: end;
}
#vote-comment .comment-name {
width: 110px;
text-align: center;
}
#vote-comment .comment-name span,
#vote-comment thead th:nth-child(2) span {
display: inline-block;
width: 50%;
}
#vote-comment tbody tr {
border-top: 1px solid gray;
}
.comment-submit {
width: 50%;
margin-left: 50%;
}
.form-control {
width: 100%;
min-height: 35.5px;
box-sizing: border-box;
padding: 5.25px 10.5px;
border: 1px solid #6c757d;
border-radius: 5.25px;
color: #fff;
background: #212529;
font: inherit;
}
.form-check-input {
width: 1em;
height: 1em;
margin: 0;
accent-color: #0d6efd;
}
.text-end {
text-align: end;
}
.text-center {
text-align: center;
}
.f_tnum {
font-variant-numeric: tabular-nums;
}
#vote-old-list,
#vote-new-panel {
padding: 0 7px;
}
.vote-old-item a,
#vote-new-panel a {
color: #6ea8fe;
}
.admin-row {
display: grid;
grid-template-columns: 25% 75%;
}
.admin-row > div {
padding: 2px 0;
}
.admin-submit {
width: 20%;
margin-left: 80%;
display: grid;
}
.bottom_bar {
height: 55.5px;
padding-top: 20px;
box-sizing: border-box;
}
.bottom_bar .back_btn {
display: inline-block;
width: auto;
}
.vote-notice {
padding: 5px 10px;
border: 1px solid #477a47;
color: #d8f5d8;
}
.vote-notice.error {
border-color: #9b4848;
color: #ffd0d0;
}
.loading {
padding: 12px;
text-align: center;
}
@media (min-width: 501px) {
.pageVote {
width: 1000px;
}
.comment-form {
flex-direction: column;
#vote-comment .comment-name {
width: 260px;
}
#vote-comment .comment-date {
width: 98px;
padding-right: 0.5ch;
padding-left: 0.5ch;
text-align: center;
}
}
@media (max-width: 500px) {
.pageVote {
width: 500px;
}
#vote-comment .comment-name {
width: 130px;
}
#vote-comment .comment-date {
width: 50px;
padding-right: 0.5ch;
padding-left: 0.5ch;
text-align: center;
}
.admin-row {
grid-template-columns: 100%;
}
}
</style>
+4 -1
View File
@@ -106,7 +106,10 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Replace smoke/placeholder tests with concrete assertions for success/failure outcomes (e.g., 등용, NPC능동 invalid args, 출병 troop creation).
- [AI suggestion] Document che*출병 parity gaps vs legacy (city state/term=43, fallback to che*이동 when friendly, nation.war/AllowWar check, post-war static events/unique-item lottery, missing route data when map/diplomacy not provided).
- [AI suggestion] Verify that "이호경식" followed by "출병" is correctly blocked with the new reserved-turn overlay (diplomacy state sync).
- [AI suggestion] Survey/unique lottery should account for auction/storage-held unique items once the inventory and auction models are finalized.
- [AI suggestion] Migrate the legacy `storage` namespaces used to reserve
unequipped unique items. Survey, monthly nation-level, and general-command
lotteries now count equipped items plus active/finalizing unique auctions,
but core2026 has no equivalent persistent storage namespace to include yet.
## Trigger System
+18 -11
View File
@@ -36,17 +36,18 @@ storage, route guards, and image loading.
## Enforced contracts
| Screen | Ref entry point | Current automated contract |
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
@@ -54,6 +55,12 @@ green, and `bg2` is blue. Shared `PanelCard` uses the same walnut body and green
header so screens that still use the common component no longer inherit the
discarded Galmuri/parchment visual system.
The survey fixture covers both the poll list and an open detail. Its result
rows are visible before the current general votes when the poll uses the
legacy-compatible `after_vote` mode. The mutation fixture covers voting and
comment submission, while the error fixture confirms that a failed vote keeps
the selected radio option so the user can retry.
## Route coverage rule
Adding or changing a frontend route requires:
@@ -144,6 +144,24 @@ export const countOccupiedUniqueItems = (
return counts;
};
export const addOccupiedUniqueItemKeys = (
counts: Map<string, number>,
itemKeys: Iterable<string | null | undefined>,
itemRegistry: Map<string, ItemModule>
): Map<string, number> => {
for (const itemKey of itemKeys) {
if (!itemKey) {
continue;
}
const module = itemRegistry.get(itemKey);
if (!module || module.buyable) {
continue;
}
counts.set(itemKey, (counts.get(itemKey) ?? 0) + 1);
}
return counts;
};
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const serializeSeed = (...values: Array<string | number>): string =>
+17
View File
@@ -5,6 +5,7 @@ import type { GeneralItemSlots } from '../src/domain/entities.js';
import type { ItemModule } from '../src/items/types.js';
import {
type UniqueAcquireType,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
resolveUniqueConfig,
@@ -131,4 +132,20 @@ describe('unique lottery', () => {
expect(counts.get('uniqueItem')).toBe(1);
expect(counts.get('buyableItem')).toBeUndefined();
});
it('adds active auction and storage reservations without counting buyable items', () => {
const itemRegistry = new Map<string, ItemModule>([
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],
['buyableItem', buildItem('buyableItem', 'book', true)],
]);
const counts = addOccupiedUniqueItemKeys(
new Map([['uniqueItem', 1]]),
['uniqueItem', 'uniqueItem', 'buyableItem', null],
itemRegistry
);
expect(counts.get('uniqueItem')).toBe(3);
expect(counts.get('buyableItem')).toBeUndefined();
});
});
@@ -163,5 +163,63 @@ export const canonicalFrontendFixture = {
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
},
},
surveyList: {
polls: [
{
id: 2,
title: '선호하는 병종',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
revealMode: 'after_vote',
optionsCount: 3,
multipleOptions: 1,
},
{
id: 1,
title: '지난 설문',
startAt: '2026-07-20T00:00:00.000Z',
endAt: '2026-07-21T00:00:00.000Z',
closedAt: '2026-07-21T00:00:00.000Z',
revealMode: 'after_vote',
optionsCount: 2,
multipleOptions: 1,
},
],
voteReward: 90,
},
surveyDetail: {
voteInfo: {
id: 2,
title: '선호하는 병종',
body: '',
options: ['보병', '기병', '궁병'],
multipleOptions: 1,
revealMode: 'after_vote',
openerGeneralId: 9,
openerName: '설문관리자',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
},
votes: [
{ selection: [0], count: 2 },
{ selection: [1], count: 1 },
],
comments: [
{
id: 1,
voteId: 2,
generalId: 3,
nationId: 1,
generalName: '관우',
nationName: '촉',
text: '기병이 좋습니다.',
createdAt: '2026-07-26T01:23:00.000Z',
},
],
myVote: null,
userCnt: 17,
},
},
} as const;
@@ -111,6 +111,8 @@ const installHallFixture = async (page: Page): Promise<void> => {
};
const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
let surveyVoted = false;
const surveyComments = fixture.game.surveyDetail.comments.map((comment) => ({ ...comment }));
await installImages(page);
await page.addInitScript(
({ gameToken, profile }) => {
@@ -139,6 +141,38 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
if (operation === 'public.getMapLayout') return fixture.game.mapLayout;
if (operation === 'yearbook.getRange') return fixture.game.yearbookRange;
if (operation === 'yearbook.getHistory') return fixture.game.yearbook;
if (operation === 'vote.getVoteList') return fixture.game.surveyList;
if (operation === 'vote.getVoteDetail') {
return {
...fixture.game.surveyDetail,
votes: surveyVoted
? [
{ selection: [0], count: 3 },
{ selection: [1], count: 1 },
]
: fixture.game.surveyDetail.votes,
comments: surveyComments,
myVote: surveyVoted ? [0] : null,
};
}
if (operation === 'vote.submitVote') {
surveyVoted = true;
return { ok: true, wonLottery: false };
}
if (operation === 'vote.addComment') {
surveyComments.push({
id: surveyComments.length + 1,
voteId: 2,
generalId: 1,
nationId: 1,
generalName: '유비',
nationName: '촉',
text: '새 댓글',
createdAt: '2026-07-26T02:34:00.000Z',
});
return { ok: true };
}
if (operation === 'vote.getAdminStatus') return { ok: false };
throw new Error(`Unhandled authenticated game fixture operation: ${operation}`);
});
});
@@ -494,3 +528,104 @@ test.describe('yearbook legacy parity', () => {
await expect(page.getByLabel('연월 선택')).toBeVisible();
});
});
test.describe('survey legacy parity', () => {
test.beforeEach(async ({ page }) => {
await installAuthenticatedGameFixture(page);
});
for (const viewport of [
{ name: 'desktop', width: 1365, height: 768, containerWidth: 1000, commentNameWidth: 260 },
{ name: 'mobile', width: 390, height: 844, containerWidth: 500, commentNameWidth: 130 },
]) {
test(`renders the ref vote tables on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto('http://127.0.0.1:15102/che/survey');
await expect(page.getByText('설문 조사(90금과 추첨으로 유니크템 증정!)')).toBeVisible();
await expect(page.getByText('기병이 좋습니다.')).toBeVisible();
await expect(page.locator('#vote-new-panel')).toHaveCount(0);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, `survey-core-${viewport.name}.png`),
fullPage: true,
animations: 'disabled',
});
}
const geometry = await page.evaluate(() => {
const rect = (selector: string) =>
document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
const container = document.querySelector<HTMLElement>('#container')!;
const title = document.querySelector<HTMLElement>('#vote-title')!;
return {
containerWidth: rect('#container').width,
containerHeight: rect('#container').height,
resultWidth: rect('#vote-result').width,
commentNameWidth: rect('#vote-comment .comment-name').width,
fontFamily: getComputedStyle(container).fontFamily,
fontSize: getComputedStyle(container).fontSize,
backgroundImage: getComputedStyle(container).backgroundImage,
title: {
height: rect('#vote-title').height,
fontSize: getComputedStyle(title).fontSize,
backgroundImage: getComputedStyle(title).backgroundImage,
},
};
});
expect(geometry.containerWidth).toBe(viewport.containerWidth);
expect(geometry.containerHeight).toBeLessThan(viewport.height);
expect(geometry.resultWidth).toBe(viewport.containerWidth);
expect(geometry.commentNameWidth).toBe(viewport.commentNameWidth);
expect(geometry.fontFamily).toContain('Pretendard');
expect(geometry.fontSize).toBe('14px');
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.title.height).toBeCloseTo(37.8, 0);
expect(geometry.title.fontSize).toBe('25.2px');
expect(geometry.title.backgroundImage).toContain('back_blue.jpg');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await expect(secondOption).toBeChecked();
await secondOption.focus();
await expect(secondOption).toBeFocused();
const voteButton = page.getByRole('button', { name: '투표', exact: true });
const beforeHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
await voteButton.hover();
const afterHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
expect(afterHover).not.toBe(beforeHover);
});
}
test('submits a vote and comment through the real screen controls', async ({ page }) => {
await page.goto('http://127.0.0.1:15102/che/survey');
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('설문을 마쳤습니다.');
await expect(page.getByText('결산', { exact: true })).toBeVisible();
await page.getByLabel('댓글').fill('새 댓글');
await page.getByRole('button', { name: '댓글 달기' }).click();
await expect(page.getByText('새 댓글')).toBeVisible();
});
test('keeps the selected option after a vote API error', async ({ page }) => {
await page.route('**/che/api/trpc/**', async (route) => {
if (operationNames(route).includes('vote.submitVote')) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: '이미 설문조사를 완료하였습니다.' } }),
});
return;
}
await route.fallback();
});
await page.goto('http://127.0.0.1:15102/che/survey');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('alert')).toBeVisible();
await expect(secondOption).toBeChecked();
});
});