test: verify inheritance parity and permissions
This commit is contained in:
@@ -0,0 +1,266 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { 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 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: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
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: 'che_선봉',
|
||||||
|
lastTurn: {},
|
||||||
|
meta: {},
|
||||||
|
penalty: {},
|
||||||
|
createdAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAuth = (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 worldState = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 3600,
|
||||||
|
config: {
|
||||||
|
const: {
|
||||||
|
availableSpecialWar: ['che_선봉'],
|
||||||
|
allItems: {
|
||||||
|
weapon: {
|
||||||
|
che_무기_12_칠성검: 1,
|
||||||
|
che_무기_01_단도: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
|
||||||
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = (options: {
|
||||||
|
auth?: GameSessionTokenPayload | null;
|
||||||
|
general?: GeneralRow | null;
|
||||||
|
target?: GeneralRow | null;
|
||||||
|
inheritancePoint?: number;
|
||||||
|
}) => {
|
||||||
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
|
const target =
|
||||||
|
options.target === undefined
|
||||||
|
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
|
||||||
|
: options.target;
|
||||||
|
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
|
||||||
|
type: command.type,
|
||||||
|
ok: true,
|
||||||
|
generalId: command.generalId,
|
||||||
|
}));
|
||||||
|
const pointUpsert = vi.fn(async () => ({}));
|
||||||
|
const logCreate = vi.fn(async () => ({}));
|
||||||
|
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||||
|
const db = {
|
||||||
|
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => worldState),
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
|
general?.userId === where.userId ? general : null
|
||||||
|
),
|
||||||
|
findMany,
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
|
target?.id === where.id ? target : null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
inheritancePoint: {
|
||||||
|
upsert: pointUpsert,
|
||||||
|
},
|
||||||
|
inheritanceLog: {
|
||||||
|
create: logCreate,
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
inheritanceUserState: {
|
||||||
|
findUnique: vi.fn(async () => null),
|
||||||
|
upsert: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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, pointUpsert, logCreate, findMany };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('inherit router actor and permission boundaries', () => {
|
||||||
|
it('rejects unauthenticated status and mutations', async () => {
|
||||||
|
const fixture = buildContext({ auth: null });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
|
||||||
|
const fixture = buildContext({});
|
||||||
|
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
|
||||||
|
|
||||||
|
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
|
||||||
|
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
|
||||||
|
expect(status.availableUnique).toEqual([
|
||||||
|
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
|
||||||
|
]);
|
||||||
|
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
|
||||||
|
expect(fixture.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
||||||
|
const fixture = buildContext({
|
||||||
|
auth: buildAuth('user-2'),
|
||||||
|
general: buildGeneral({ userId: 'user-1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||||
|
type: 'domesticSuccessProb',
|
||||||
|
level: 1,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '장수가 존재하지 않습니다.',
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mutates only the authenticated user general and inheritance balance', async () => {
|
||||||
|
const fixture = buildContext({ inheritancePoint: 1000 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||||
|
type: 'domesticSuccessProb',
|
||||||
|
level: 1,
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ ok: true, remainPoint: 800 });
|
||||||
|
|
||||||
|
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'patchGeneral',
|
||||||
|
generalId: 7,
|
||||||
|
patch: expect.objectContaining({
|
||||||
|
meta: expect.objectContaining({
|
||||||
|
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||||
|
update: { value: 800 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||||
|
const fixture = buildContext({ inheritancePoint: 1500 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
ownerName: '위유저',
|
||||||
|
targetName: '조조',
|
||||||
|
});
|
||||||
|
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||||
|
update: { value: 500 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: 'user-1',
|
||||||
|
year: 200,
|
||||||
|
month: 4,
|
||||||
|
text: '1000 포인트로 장수 소유자 확인',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,22 +43,23 @@ storage, route guards, and image loading.
|
|||||||
|
|
||||||
## Enforced contracts
|
## Enforced contracts
|
||||||
|
|
||||||
| Screen | Ref entry point | Current automated contract |
|
| 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 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 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 |
|
| 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/` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API 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 |
|
| 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 |
|
||||||
| 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 |
|
| 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 |
|
||||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
| 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 |
|
||||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||||
|
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||||
|
|
||||||
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
||||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { General } from '../src/domain/entities.js';
|
||||||
|
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
|
||||||
|
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
|
||||||
|
|
||||||
|
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Tester',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
injury: 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 20,
|
||||||
|
npcState: 0,
|
||||||
|
triggerState: {
|
||||||
|
flags: {},
|
||||||
|
counters: {},
|
||||||
|
modifiers: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inheritance buff legacy keys', () => {
|
||||||
|
it('applies the canonical legacy domestic buff names', () => {
|
||||||
|
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||||
|
const context = {
|
||||||
|
general: buildGeneral({
|
||||||
|
domesticSuccessProb: 3,
|
||||||
|
domesticFailProb: 2,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53);
|
||||||
|
expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues to read the earlier core success and fail aliases', () => {
|
||||||
|
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||||
|
const context = {
|
||||||
|
general: buildGeneral({
|
||||||
|
success: 2,
|
||||||
|
fail: 1,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52);
|
||||||
|
expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, extname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const imageRoot = resolve(repositoryRoot, '../../image');
|
||||||
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
|
||||||
|
const operations = (route: Route): string[] => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const installImages = async (page: Page): Promise<void> => {
|
||||||
|
await page.route('**/image/**', async (route) => {
|
||||||
|
const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, '');
|
||||||
|
for (const candidate of [
|
||||||
|
resolve(imageRoot, relative),
|
||||||
|
resolve(imageRoot, 'game', relative),
|
||||||
|
resolve(imageRoot, 'icons', '22.jpg'),
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
const body = await readFile(candidate);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// 다음 공개 image root 후보를 확인한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await route.abort('failed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFixture = {
|
||||||
|
items: {
|
||||||
|
previous: 12_000,
|
||||||
|
lived_month: 240,
|
||||||
|
max_domestic_critical: 80,
|
||||||
|
active_action: 35,
|
||||||
|
combat: 150,
|
||||||
|
sabotage: 60,
|
||||||
|
dex: 42,
|
||||||
|
unifier: 0,
|
||||||
|
tournament: 30,
|
||||||
|
betting: 20,
|
||||||
|
max_belong: 8,
|
||||||
|
},
|
||||||
|
totalPoint: 12_665,
|
||||||
|
inheritConst: {
|
||||||
|
minMonthToAllowInheritItem: 4,
|
||||||
|
inheritBornSpecialPoint: 6000,
|
||||||
|
inheritBornTurntimePoint: 2500,
|
||||||
|
inheritBornCityPoint: 1000,
|
||||||
|
inheritBornStatPoint: 1000,
|
||||||
|
inheritItemUniqueMinPoint: 5000,
|
||||||
|
inheritItemRandomPoint: 3000,
|
||||||
|
inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000],
|
||||||
|
inheritSpecificSpecialPoint: 4000,
|
||||||
|
inheritResetAttrPointBase: [1000, 1000, 2000, 3000],
|
||||||
|
inheritCheckOwnerPoint: 1000,
|
||||||
|
},
|
||||||
|
buffLevels: {
|
||||||
|
warAvoidRatio: 0,
|
||||||
|
warCriticalRatio: 1,
|
||||||
|
warMagicTrialProb: 0,
|
||||||
|
domesticSuccessProb: 0,
|
||||||
|
domesticFailProb: 0,
|
||||||
|
warAvoidRatioOppose: 0,
|
||||||
|
warCriticalRatioOppose: 0,
|
||||||
|
warMagicTrialProbOppose: 0,
|
||||||
|
},
|
||||||
|
resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 },
|
||||||
|
resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 },
|
||||||
|
availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }],
|
||||||
|
availableUnique: [
|
||||||
|
{
|
||||||
|
key: 'che_무기_12_칠성검',
|
||||||
|
name: '칠성검(+12)',
|
||||||
|
rawName: '칠성검',
|
||||||
|
info: '무력을 올려주는 유니크 무기입니다.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availableTargetGenerals: [{ id: 8, name: '조조' }],
|
||||||
|
turnTimeZones: ['00:00'],
|
||||||
|
isUnited: false,
|
||||||
|
currentSpecialWar: 'che_선봉',
|
||||||
|
currentStat: { leadership: 70, strength: 45, intel: 85 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
||||||
|
let buffMutationCount = 0;
|
||||||
|
await installImages(page);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
|
||||||
|
window.localStorage.setItem('sammo-game-profile', 'che');
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const names = operations(route);
|
||||||
|
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = names.map((name) => {
|
||||||
|
if (name === 'inherit.getStatus') return response(statusFixture);
|
||||||
|
if (name === 'lobby.info') {
|
||||||
|
return response({
|
||||||
|
profile: { id: 'che', scenario: 'default', name: '체섭' },
|
||||||
|
world: { year: 200, month: 4 },
|
||||||
|
myGeneral: { id: 7, name: '유비', nationId: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (name === 'inherit.getLogs') {
|
||||||
|
return response([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
year: 200,
|
||||||
|
month: 4,
|
||||||
|
text: '1000 포인트로 장수 소유자 확인',
|
||||||
|
createdAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (name === 'join.getConfig') {
|
||||||
|
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
|
||||||
|
}
|
||||||
|
if (name === 'inherit.buyHiddenBuff') {
|
||||||
|
buffMutationCount += 1;
|
||||||
|
return response({ ok: true, remainPoint: 11_800 });
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(result),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { buffMutationCount: () => buffMutationCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('inheritance management legacy parity', () => {
|
||||||
|
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
|
||||||
|
await installFixture(page);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/inherit');
|
||||||
|
await expect(page.locator('#container')).toBeVisible();
|
||||||
|
await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검');
|
||||||
|
|
||||||
|
const desktop = await page.evaluate(() => {
|
||||||
|
const rect = (selector: string) => {
|
||||||
|
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||||
|
return { x: box.x, width: box.width };
|
||||||
|
};
|
||||||
|
const container = getComputedStyle(document.querySelector<HTMLElement>('#container')!);
|
||||||
|
const title = getComputedStyle(document.querySelector<HTMLElement>('.section-title')!);
|
||||||
|
const button = getComputedStyle(document.querySelector<HTMLElement>('.buy-button')!);
|
||||||
|
return {
|
||||||
|
container: rect('#container'),
|
||||||
|
firstPoint: rect('#inherit_sum'),
|
||||||
|
fontFamily: container.fontFamily,
|
||||||
|
fontSize: container.fontSize,
|
||||||
|
backgroundImage: container.backgroundImage,
|
||||||
|
titleBackgroundImage: title.backgroundImage,
|
||||||
|
buttonBackground: button.backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(desktop.container.width).toBe(1000);
|
||||||
|
expect(desktop.container.x).toBe(140);
|
||||||
|
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
|
||||||
|
expect(desktop.fontFamily).toContain('Pretendard');
|
||||||
|
expect(desktop.fontSize).toBe('14px');
|
||||||
|
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||||
|
expect(desktop.titleBackgroundImage).toContain('back_green.jpg');
|
||||||
|
|
||||||
|
const buyButton = page.locator('.buy-button').first();
|
||||||
|
const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
|
await buyButton.hover();
|
||||||
|
const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
|
expect(afterHover).not.toBe(beforeHover);
|
||||||
|
await buyButton.focus();
|
||||||
|
await expect(buyButton).toBeFocused();
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('#container')).toBeVisible();
|
||||||
|
const mobile = await page.evaluate(() => {
|
||||||
|
const container = document.querySelector<HTMLElement>('#container')!.getBoundingClientRect();
|
||||||
|
const first = document.querySelector<HTMLElement>('#inherit_sum')!.getBoundingClientRect();
|
||||||
|
const second = document.querySelector<HTMLElement>('#inherit_previous')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
containerWidth: container.width,
|
||||||
|
firstWidth: first.width,
|
||||||
|
stacked: second.y > first.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobile.containerWidth).toBe(500);
|
||||||
|
expect(mobile.firstWidth).toBeCloseTo(482, 0);
|
||||||
|
expect(mobile.stacked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => {
|
||||||
|
const fixture = await installFixture(page);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/inherit');
|
||||||
|
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||||
|
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||||
|
await expect.poll(fixture.buffMutationCount).toBe(1);
|
||||||
|
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
|
||||||
|
await installFixture(page, { failBuff: true });
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/inherit');
|
||||||
|
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||||
|
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||||
|
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1');
|
||||||
|
await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
'public-gaps.spec.ts',
|
'public-gaps.spec.ts',
|
||||||
'instant-diplomacy-message.spec.ts',
|
'instant-diplomacy-message.spec.ts',
|
||||||
'tournament-betting.spec.ts',
|
'tournament-betting.spec.ts',
|
||||||
|
'inheritance-management.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user