feat : custom stats with double price in commission
This commit is contained in:
@@ -8,6 +8,7 @@ const transaction = vi.fn();
|
||||
const forUpdate = vi.fn();
|
||||
const returning = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const values = vi.fn();
|
||||
|
||||
vi.mock("@/lib/commission/server", () => ({ requireCommissionUser }));
|
||||
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest }));
|
||||
@@ -17,10 +18,12 @@ vi.mock("@/db", () => ({ getDb: () => ({ transaction }) }));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
|
||||
function request() {
|
||||
function request(customStats?: string) {
|
||||
return new Request("https://guide.sudloh.com/api/commission/checkouts", {
|
||||
method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ teams: [], weapons: [{ characterKey: "c", weaponKeys: ["w1", "w2"] }],
|
||||
body: JSON.stringify({ teams: customStats === undefined ? [] : [{ members: ["a", "b", "c", "d"].map((characterKey, index) => ({
|
||||
characterKey, weaponKey: "w1", artifactKey: "artifact", customStats: index === 0 ? customStats : undefined,
|
||||
})) }], weapons: [{ characterKey: "c", weaponKeys: ["w1", "w2"] }],
|
||||
constellations: [] }),
|
||||
});
|
||||
}
|
||||
@@ -37,7 +40,8 @@ describe("commission checkout availability", () => {
|
||||
select: () => ({ from: () => ({ where: () => ({ for: forUpdate }) }) }),
|
||||
insert,
|
||||
}));
|
||||
insert.mockReturnValue({ values: () => ({ returning }) });
|
||||
insert.mockReturnValue({ values });
|
||||
values.mockReturnValue({ returning });
|
||||
returning.mockResolvedValue([{ id: "checkout-1" }]);
|
||||
});
|
||||
|
||||
@@ -55,5 +59,35 @@ describe("commission checkout availability", () => {
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.json()).toEqual({ checkoutId: "checkout-1" });
|
||||
expect(insert).toHaveBeenCalledOnce();
|
||||
expect(values).toHaveBeenCalledWith(expect.objectContaining({ amountBaht: 140 }));
|
||||
});
|
||||
it("stores custom stats and charges twice the total on the server", async () => {
|
||||
forUpdate.mockResolvedValue([{ isOpen: true }]);
|
||||
const response = await POST(request("ATK 2000"));
|
||||
expect(response.status).toBe(201);
|
||||
expect(values).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amountBaht: 280, request: expect.objectContaining({ teams: [{ members: [
|
||||
expect.objectContaining({ characterKey: "a", customStats: "ATK 2000" }),
|
||||
expect.objectContaining({ characterKey: "b" }), expect.objectContaining({ characterKey: "c" }),
|
||||
expect.objectContaining({ characterKey: "d" }),
|
||||
] }] }),
|
||||
}));
|
||||
});
|
||||
it("charges twice the total for custom stats on either comparison type without a team", async () => {
|
||||
forUpdate.mockResolvedValue([{ isOpen: true }]);
|
||||
for (const kind of ["weapons", "constellations"]) {
|
||||
values.mockClear();
|
||||
const body = { teams: [], weapons: [], constellations: [], [kind]: kind === "weapons"
|
||||
? [{ characterKey: "a", weaponKeys: ["one", "two"], customStats: "ATK 2000" }]
|
||||
: [{ characterKey: "a", levels: [0, 1], customStats: "ER 180%" }] };
|
||||
const response = await POST(new Request("https://guide.sudloh.com/api/commission/checkouts", {
|
||||
method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
expect(response.status).toBe(201);
|
||||
expect(values).toHaveBeenCalledWith(expect.objectContaining({ amountBaht: 280,
|
||||
request: expect.objectContaining({ [kind]: expect.arrayContaining([expect.objectContaining({ customStats: body[kind][0].customStats })]) }),
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ export function CommissionIntro() {
|
||||
<p className="flex justify-between gap-3"><span>จัดทีม 4 ตัวละคร</span><strong>฿100 ต่อทีม</strong></p>
|
||||
<p className="flex justify-between gap-3"><span>เปรียบเทียบอาวุธ</span><strong>฿20 ต่ออาวุธ</strong></p>
|
||||
<p className="flex justify-between gap-3"><span>เปรียบเทียบกลุ่มดาว</span><strong>฿20 ต่อระดับ</strong></p>
|
||||
<p className="flex justify-between gap-3"><span>กำหนดค่าสเตตัสเอง (Custom stats)</span><strong>ยอดรวมทั้งหมด ×2</strong></p>
|
||||
<p className="border-t pt-3 text-muted-foreground">หากไม่เลือกทีม จะมีค่าบริการพื้นฐาน ฿100</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -9,9 +9,10 @@ import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/opt
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FieldError, FieldGroup, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { commissionPrice, type CommissionRequest } from "@/lib/commission/request";
|
||||
import { commissionPrice, hasCustomStats, type CommissionRequest } from "@/lib/commission/request";
|
||||
import { commissionDraftKey, restoreCommissionDraft, serializeCommissionDraft } from "@/lib/commission/draft";
|
||||
import { commissionRequestIssues } from "@/lib/commission/request-validation";
|
||||
import { elementBackgroundColor } from "@/lib/catalog/element";
|
||||
@@ -24,6 +25,26 @@ const blankMember = (): CommissionRequest["teams"][number]["members"][number] =>
|
||||
});
|
||||
const blankTeam = () => ({ members: Array.from({ length: 4 }, blankMember) });
|
||||
|
||||
function CustomStatsFields({ id, characterName, value, disabled, invalid, onValueChange }: {
|
||||
id: string; characterName: string; value: string | undefined; disabled: boolean;
|
||||
invalid: boolean; onValueChange: (value: string | undefined) => void;
|
||||
}) {
|
||||
return <FieldGroup className="mt-2 gap-2">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id={id} disabled={disabled} checked={value !== undefined}
|
||||
onCheckedChange={(checked) => onValueChange(checked ? "" : undefined)} />
|
||||
<FieldLabel htmlFor={id}>Custom stats</FieldLabel>
|
||||
</Field>
|
||||
{value !== undefined && <Field data-invalid={invalid}>
|
||||
<FieldLabel htmlFor={`${id}-details`}>ค่าสเตตัสของ {characterName}</FieldLabel>
|
||||
<Textarea id={`${id}-details`} value={value} maxLength={4000} required aria-invalid={invalid}
|
||||
aria-describedby={`${id}-help`} placeholder="ATK 2,000, CRIT Rate 70%, CRIT DMG 140%, ER 180%"
|
||||
onChange={(event) => onValueChange(event.target.value)} />
|
||||
<FieldDescription id={`${id}-help`}>ราคาทั้งคำขอ ×2 เมื่อมีตัวละครใช้ Custom stats</FieldDescription>
|
||||
</Field>}
|
||||
</FieldGroup>;
|
||||
}
|
||||
|
||||
function checkoutError(code: string | undefined, status: number): string {
|
||||
if (status === 401) return "เซสชันหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง";
|
||||
if (status === 403) return "ไม่สามารถส่งคำขอจากหน้านี้ได้ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง";
|
||||
@@ -123,7 +144,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
|
||||
<p className="text-sm text-muted-foreground" role="status">{draftNotice}</p>
|
||||
<p className="text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน</p>
|
||||
{attempted && issues.some((issue) => !issue.path) && <Alert variant="destructive" role="alert"><AlertDescription>เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ</AlertDescription></Alert>}
|
||||
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว</CardDescription></CardHeader>
|
||||
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว หากกำหนดค่าสเตตัสเองให้ตัวละครใด ราคาทั้งคำขอจะเป็น 2 เท่าของยอดรวมปกติ</CardDescription></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{request.teams.map((team, teamIndex) => <Card key={teamIndex} className="w-full"><CardHeader><CardTitle>ทีม {teamIndex + 1}</CardTitle></CardHeader><CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
@@ -171,6 +192,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
|
||||
}} triggerProps={{ size: "sm", className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden" }} />
|
||||
</div>
|
||||
<p className="mt-1 truncate text-center text-xs font-medium" title={character?.name}>{character?.name ?? "เลือกตัวละคร"}</p>
|
||||
<CustomStatsFields id={`custom-stats-team-${teamIndex}-${memberIndex}`}
|
||||
characterName={character?.name ?? `ตัวละครที่ ${memberIndex + 1}`} value={member.customStats}
|
||||
disabled={!character} invalid={invalid(`teams.${teamIndex}.members.${memberIndex}.customStats`)}
|
||||
onValueChange={(customStats) => {
|
||||
const teams = structuredClone(request.teams);
|
||||
teams[teamIndex].members[memberIndex].customStats = customStats;
|
||||
patch({ teams });
|
||||
}} />
|
||||
{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).length > 0 && <FieldError>{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).join(" - ")}</FieldError>}
|
||||
</div>;
|
||||
})}
|
||||
@@ -234,6 +263,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
|
||||
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CustomStatsFields id={`custom-stats-weapons-${index}`}
|
||||
characterName={catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "ตัวละคร"}
|
||||
value={set.customStats} disabled={!set.characterKey} invalid={invalid(`weapons.${index}.customStats`)}
|
||||
onValueChange={(customStats) => {
|
||||
const weapons = structuredClone(request.weapons);
|
||||
weapons[index].customStats = customStats;
|
||||
patch({ weapons });
|
||||
}} />
|
||||
{errorsAt(`weapons.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`weapons.${index}`).join(" - ")}</FieldError>}
|
||||
<Button variant="outline" disabled={set.weaponKeys.length >= 100} onClick={() => {
|
||||
const weapons = structuredClone(request.weapons); weapons[index].weaponKeys.push(""); patch({ weapons });
|
||||
@@ -263,6 +300,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
|
||||
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CustomStatsFields id={`custom-stats-constellations-${index}`}
|
||||
characterName={catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "ตัวละคร"}
|
||||
value={set.customStats} disabled={!set.characterKey} invalid={invalid(`constellations.${index}.customStats`)}
|
||||
onValueChange={(customStats) => {
|
||||
const constellations = structuredClone(request.constellations);
|
||||
constellations[index].customStats = customStats;
|
||||
patch({ constellations });
|
||||
}} />
|
||||
{errorsAt(`constellations.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`constellations.${index}`).join(" - ")}</FieldError>}
|
||||
<Button variant="outline" onClick={() => patch({ constellations: request.constellations.filter((_, i) => i !== index) })}>ลบชุดนี้</Button>
|
||||
</FieldGroup>)}
|
||||
@@ -275,6 +320,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
|
||||
<CardFooter className="flex flex-wrap items-center justify-between gap-3">
|
||||
{error && <Alert variant="destructive" role="alert" className="w-full"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
<div><strong className="text-lg">รวม ฿{commissionPrice(request)}</strong>
|
||||
{hasCustomStats(request) && <p className="text-sm text-muted-foreground">Custom stats: ยอดรวมปกติ ฿{commissionPrice(request) / 2} ×2</p>}
|
||||
<p className="text-xs text-muted-foreground sm:hidden">{request.teams.length} ทีม - {request.weapons.reduce((sum, set) => sum + set.weaponKeys.filter(Boolean).length, 0)} อาวุธ - {request.constellations.reduce((sum, set) => sum + set.levels.length, 0)} ระดับกลุ่มดาว</p>
|
||||
{request.teams.length === 0 && request.weapons.length + request.constellations.length > 0 && <p className="text-sm text-muted-foreground">รวมค่าบริการพื้นฐาน ฿100</p>}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { elementBackgroundColor } from "@/lib/catalog/element";
|
||||
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import type { CommissionRequest } from "@/lib/commission/request";
|
||||
import { hasCustomStats, type CommissionRequest } from "@/lib/commission/request";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Item = { key: string; name: string; imageUrl: string; rarity?: number | null; element?: string | null };
|
||||
@@ -60,6 +60,10 @@ function CommissionTeamCard({ team, index, catalog }: { team: CommissionRequest[
|
||||
<Badge variant="secondary" className="h-4 px-0.5 text-[9px] lg:h-auto lg:px-2 lg:text-xs">R{member.refinement ?? "1"}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-center text-[10px] font-medium lg:text-xs" title={character?.name}>{character?.name ?? member.characterKey}</p>
|
||||
{member.customStats !== undefined && <div className="mt-2 flex flex-col gap-1">
|
||||
<Badge variant="secondary">Custom stats</Badge>
|
||||
<p className="whitespace-pre-wrap wrap-break-word text-xs">{member.customStats}</p>
|
||||
</div>}
|
||||
</div>;
|
||||
})}
|
||||
</CardContent>
|
||||
@@ -75,6 +79,14 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
|
||||
request.constellations.reduce((sum, set) => sum + set.levels.length * 20, 0);
|
||||
|
||||
return <div className="@container flex flex-col gap-4">
|
||||
{hasCustomStats(request) && <Card size="sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle>กำหนดค่าสเตตัสเอง (Custom stats)</CardTitle><Badge variant="secondary">ราคาทั้งหมด ×2</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">เพิ่มจากยอดรวมปกติ ฿{amountBaht / 2} รวมทั้งหมด ฿{amountBaht}</p>
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{request.teams.length === 0 && amountBaht > comparisonTotal ?
|
||||
<Card size="sm"><CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle>ค่าบริการพื้นฐาน</CardTitle><Badge variant="secondary">฿100</Badge>
|
||||
@@ -98,6 +110,10 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
|
||||
})}
|
||||
</div>
|
||||
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" character /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
|
||||
{set.customStats !== undefined && <div className="col-span-2 flex flex-col gap-1">
|
||||
<Badge variant="secondary">Custom stats ของ {character?.name ?? set.characterKey}</Badge>
|
||||
<p className="whitespace-pre-wrap wrap-break-word text-sm">{set.customStats}</p>
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
})}
|
||||
@@ -110,6 +126,10 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
|
||||
<CardContent className="grid grid-cols-[minmax(0,1fr)_64px] items-center gap-3">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">{set.levels.map((level) => <Badge key={level} variant="outline">C{level}</Badge>)}</div>
|
||||
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" character /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
|
||||
{set.customStats !== undefined && <div className="col-span-2 flex flex-col gap-1">
|
||||
<Badge variant="secondary">Custom stats ของ {character?.name ?? set.characterKey}</Badge>
|
||||
<p className="whitespace-pre-wrap wrap-break-word text-sm">{set.customStats}</p>
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
})}
|
||||
|
||||
@@ -16,6 +16,14 @@ const request: CommissionRequest = {
|
||||
};
|
||||
|
||||
describe("commission draft recovery", () => {
|
||||
it("preserves separate character stats including an enabled but unfinished field", () => {
|
||||
const custom = structuredClone(request);
|
||||
custom.teams[0].members[0].customStats = "ATK 2000\nCRIT Rate 70%";
|
||||
custom.teams[0].members[1].customStats = "";
|
||||
custom.weapons[0].customStats = "CRIT Rate 70%";
|
||||
custom.constellations[0].customStats = "";
|
||||
expect(restoreCommissionDraft(serializeCommissionDraft(custom, now), catalog, now)).toEqual(custom);
|
||||
});
|
||||
it("preserves a partially completed request and its refinements", () => {
|
||||
expect(restoreCommissionDraft(serializeCommissionDraft(request, now), catalog, now)).toEqual(request);
|
||||
});
|
||||
|
||||
@@ -7,12 +7,15 @@ const draftRequestSchema = z.object({
|
||||
characterKey: draftKey, weaponKey: draftKey, artifactKey: draftKey,
|
||||
constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]),
|
||||
refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]),
|
||||
customStats: z.string().max(4000).optional(),
|
||||
})).length(4) })).max(20),
|
||||
weapons: z.array(z.object({
|
||||
customStats: z.string().max(4000).optional(),
|
||||
characterKey: draftKey, weaponKeys: z.array(draftKey).min(1).max(100),
|
||||
refinements: z.record(draftKey, z.enum(["1", "2", "3", "4", "5"])).optional(),
|
||||
})).max(20),
|
||||
constellations: z.array(z.object({
|
||||
customStats: z.string().max(4000).optional(),
|
||||
characterKey: draftKey, levels: z.array(z.number().int().min(0).max(6)).max(7),
|
||||
})).max(20),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,14 @@ import type { CommissionRequest } from "./request";
|
||||
const member = (key: string) => ({ characterKey: key, weaponKey: "weapon", artifactKey: "artifact", constellation: "0", refinement: "1" } as const);
|
||||
|
||||
describe("commission request feedback", () => {
|
||||
it("identifies missing custom stats in each comparison", () => {
|
||||
const issues = commissionRequestIssues({ teams: [],
|
||||
weapons: [{ characterKey: "a", weaponKeys: ["one", "two"], customStats: "" }],
|
||||
constellations: [{ characterKey: "a", levels: [0], customStats: " " }],
|
||||
});
|
||||
expect(issues.map((issue) => issue.path)).toEqual(["weapons.0.customStats", "constellations.0.customStats"]);
|
||||
expect(issues.every((issue) => issue.message.includes("ค่าสเตตัส"))).toBe(true);
|
||||
});
|
||||
it("identifies each missing selection so errors can appear beside their pickers", () => {
|
||||
const request: CommissionRequest = { teams: [{ members: [member("a"), member("b"), { ...member("c"), weaponKey: "", artifactKey: "" }, member("d")] }], weapons: [], constellations: [] };
|
||||
expect(commissionRequestIssues(request)).toEqual([
|
||||
|
||||
@@ -11,10 +11,15 @@ export function commissionRequestIssues(request: CommissionRequest) {
|
||||
message = memberNumber && path[4] === "characterKey" ? `ทีม ${teamNumber}: เลือกตัวละครที่ ${memberNumber}`
|
||||
: memberNumber && path[4] === "weaponKey" ? `ทีม ${teamNumber}: เลือกอาวุธให้ตัวละครที่ ${memberNumber}`
|
||||
: memberNumber && path[4] === "artifactKey" ? `ทีม ${teamNumber}: เลือกอาร์ติแฟกต์ให้ตัวละครที่ ${memberNumber}`
|
||||
: memberNumber && path[4] === "customStats" ? `ทีม ${teamNumber}: ระบุค่าสเตตัสให้ตัวละครที่ ${memberNumber} ไม่เกิน 4,000 ตัวอักษร`
|
||||
: `ทีม ${teamNumber}: เลือกตัวละคร 4 ตัวที่ไม่ซ้ำกัน พร้อมอาวุธและอาร์ติแฟกต์`;
|
||||
}
|
||||
if (path[0] === "weapons") message = `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: เลือกตัวละครและอาวุธต่างกันอย่างน้อย 2 ชิ้น`;
|
||||
if (path[0] === "constellations") message = `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ`;
|
||||
if (path[0] === "weapons") message = path[2] === "customStats"
|
||||
? `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: ระบุค่าสเตตัสที่ต้องการ ไม่เกิน 4,000 ตัวอักษร`
|
||||
: `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: เลือกตัวละครและอาวุธต่างกันอย่างน้อย 2 ชิ้น`;
|
||||
if (path[0] === "constellations") message = path[2] === "customStats"
|
||||
? `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: ระบุค่าสเตตัสที่ต้องการ ไม่เกิน 4,000 ตัวอักษร`
|
||||
: `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ`;
|
||||
return { path: path.join("."), message };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,47 @@ const request = {
|
||||
};
|
||||
|
||||
describe("commission request pricing and shape", () => {
|
||||
it("doubles weapon and constellation comparisons including the base fee", () => {
|
||||
for (const kind of ["weapons", "constellations"] as const) {
|
||||
const standard = commissionRequestSchema.parse({ teams: [], weapons: [], constellations: [], [kind]: request[kind] });
|
||||
const custom = commissionRequestSchema.parse({ ...standard, [kind]: standard[kind].map((set) => ({ ...set, customStats: " ER 180% " })) });
|
||||
expect(custom[kind][0].customStats).toBe("ER 180%");
|
||||
expect(commissionPrice(custom)).toBe(commissionPrice(standard) * 2);
|
||||
delete custom[kind][0].customStats;
|
||||
expect(commissionPrice(custom)).toBe(commissionPrice(standard));
|
||||
for (const customStats of ["", " ", "a".repeat(4001)])
|
||||
expect(commissionRequestSchema.safeParse({ ...standard, [kind]: standard[kind].map((set) => ({ ...set, customStats })) }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
it("applies the multiplier once when teams and both comparison types have custom stats", () => {
|
||||
const custom = commissionRequestSchema.parse({ ...request,
|
||||
teams: request.teams.map((team) => ({ members: team.members.map((member) => ({ ...member, customStats: "ATK 2000" })) })),
|
||||
weapons: request.weapons.map((set) => ({ ...set, customStats: "CRIT Rate 70%" })),
|
||||
constellations: request.constellations.map((set) => ({ ...set, customStats: "ER 180%" })),
|
||||
});
|
||||
expect(commissionPrice(custom)).toBe(400);
|
||||
});
|
||||
it("doubles the entire subtotal once when any team character has custom stats", () => {
|
||||
const standard = commissionRequestSchema.parse(request);
|
||||
for (const count of [1, 2, 4]) {
|
||||
const custom = commissionRequestSchema.parse({ ...request, teams: [{ members: request.teams[0].members.map((member, index) =>
|
||||
index < count ? { ...member, customStats: ` ATK ${2000 + index} ` } : member) }] });
|
||||
expect(commissionPrice(custom)).toBe(commissionPrice(standard) * 2);
|
||||
expect(custom.teams[0].members[0].customStats).toBe("ATK 2000");
|
||||
expect(custom.teams[0].members[1].customStats).toBe(count > 1 ? "ATK 2001" : undefined);
|
||||
}
|
||||
const custom = commissionRequestSchema.parse({ teams: request.teams.concat([{ members: request.teams[0].members.map((member, index) =>
|
||||
index === 3 ? { ...member, customStats: "ER 180%" } : member) }]), weapons: [], constellations: [] });
|
||||
expect(commissionPrice(custom)).toBe(400);
|
||||
delete custom.teams[1].members[3].customStats;
|
||||
expect(commissionPrice(custom)).toBe(200);
|
||||
});
|
||||
it("requires nonblank, bounded custom stats for each selected character", () => {
|
||||
for (const customStats of ["", " ", "a".repeat(4001), true]) {
|
||||
expect(commissionRequestSchema.safeParse({ ...request, teams: [{ members: request.teams[0].members.map((member, index) =>
|
||||
index === 0 ? { ...member, customStats } : member) }] }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
it("accepts one complete team without either comparison type", () => {
|
||||
const teamOnly = { teams: request.teams, weapons: [], constellations: [] };
|
||||
const parsed = commissionRequestSchema.safeParse(teamOnly);
|
||||
|
||||
@@ -7,6 +7,7 @@ const member = z.object({
|
||||
artifactKey: key,
|
||||
constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]).default("0"),
|
||||
refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]).default("1"),
|
||||
customStats: z.string().trim().min(1).max(4000).optional(),
|
||||
});
|
||||
const team = z.object({ members: z.array(member).length(4) }).refine(
|
||||
({ members }) => new Set(members.map((item) => item.characterKey)).size === 4,
|
||||
@@ -14,6 +15,7 @@ const team = z.object({ members: z.array(member).length(4) }).refine(
|
||||
);
|
||||
const weaponComparison = z.object({
|
||||
characterKey: key,
|
||||
customStats: z.string().trim().min(1).max(4000).optional(),
|
||||
weaponKeys: z.array(key).min(2).max(100),
|
||||
refinements: z.record(key, z.enum(["1", "2", "3", "4", "5"])).optional(),
|
||||
}).refine(({ weaponKeys }) => new Set(weaponKeys).size === weaponKeys.length, "Choose different weapons")
|
||||
@@ -22,6 +24,7 @@ const weaponComparison = z.object({
|
||||
"Refinements must belong to selected weapons");
|
||||
const constellationComparison = z.object({
|
||||
characterKey: key,
|
||||
customStats: z.string().trim().min(1).max(4000).optional(),
|
||||
levels: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
||||
}).refine(({ levels }) => new Set(levels).size === levels.length, "Choose different constellation levels");
|
||||
|
||||
@@ -34,9 +37,16 @@ export const commissionRequestSchema = z.object({
|
||||
|
||||
export type CommissionRequest = z.infer<typeof commissionRequestSchema>;
|
||||
|
||||
export function hasCustomStats(request: CommissionRequest): boolean {
|
||||
return request.teams.some((team) => team.members.some((member) => member.customStats !== undefined)) ||
|
||||
request.weapons.some((set) => set.customStats !== undefined) ||
|
||||
request.constellations.some((set) => set.customStats !== undefined);
|
||||
}
|
||||
|
||||
export function commissionPrice(request: CommissionRequest): number {
|
||||
const hasItems = request.teams.length + request.weapons.length + request.constellations.length > 0;
|
||||
return (hasItems ? Math.max(1, request.teams.length) * 100 : 0) +
|
||||
const subtotal = (hasItems ? Math.max(1, request.teams.length) * 100 : 0) +
|
||||
request.weapons.reduce((sum, item) => sum + item.weaponKeys.filter(Boolean).length * 20, 0) +
|
||||
request.constellations.reduce((sum, item) => sum + item.levels.length * 20, 0);
|
||||
return subtotal * (hasCustomStats(request) ? 2 : 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user