fix: Update PartyAdapter to match corrected API endpoints

- Remove non-existent batch update methods for grid items
- Add gridUpdate for atomic batch operations
- Add preview management methods
- Split job management into separate endpoints
- Update tests to match new API structure
This commit is contained in:
Justin Edmund 2025-09-20 00:04:36 -07:00
parent 114427241f
commit 4f8beab3ea
28 changed files with 1254 additions and 305 deletions

View file

@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { PartyAdapter } from '../party.adapter'
import type { Party, GridWeapon, GridSummon, GridCharacter } from '../party.adapter'
import type { Party } from '../party.adapter'
describe('PartyAdapter', () => {
let adapter: PartyAdapter
@ -202,188 +202,78 @@ describe('PartyAdapter', () => {
})
describe('grid management', () => {
it('should update grid weapons', async () => {
const mockGridWeapons: GridWeapon[] = [
{
id: 'gw-1',
position: 1,
mainhand: true,
uncapLevel: 5,
transcendenceStage: 0,
weaponKeys: [],
weapon: {
id: 'weapon-1',
granblueId: 'w-1',
name: { en: 'Sword' },
element: 1,
rarity: 5
}
}
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
grid_weapons: mockGridWeapons.map(gw => ({
...gw,
uncap_level: gw.uncapLevel,
transcendence_stage: gw.transcendenceStage,
weapon_keys: gw.weaponKeys
}))
})
})
const result = await adapter.updateGridWeapons({
shortcode: 'ABC123',
updates: [
it('should perform batch grid updates', async () => {
const mockResponse = {
party: mockParty,
operations_applied: 2,
changes: [
{
position: 1,
weaponId: 'weapon-1',
mainhand: true,
uncapLevel: 5
entity: 'weapon',
id: 'gw-1',
action: 'moved',
from: 1,
to: 2
},
{
entity: 'character',
id: 'gc-1',
action: 'swapped',
with: 'gc-2'
}
]
})
expect(result.gridWeapons).toEqual(mockGridWeapons)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123/grid_weapons',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
grid_weapons: [
{
position: 1,
weapon_id: 'weapon-1',
mainhand: true,
uncap_level: 5
}
]
})
})
)
})
it('should update grid summons', async () => {
const mockGridSummons: GridSummon[] = [
{
id: 'gs-1',
position: 1,
quickSummon: true,
transcendenceStage: 2,
summon: {
id: 'summon-1',
granblueId: 's-1',
name: { en: 'Bahamut' },
element: 6,
rarity: 5
}
}
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
grid_summons: mockGridSummons.map(gs => ({
...gs,
quick_summon: gs.quickSummon,
transcendence_stage: gs.transcendenceStage
}))
})
})
const result = await adapter.updateGridSummons({
shortcode: 'ABC123',
updates: [
{
position: 1,
summonId: 'summon-1',
quickSummon: true,
transcendenceStage: 2
}
]
})
expect(result.gridSummons).toEqual(mockGridSummons)
})
it('should update grid characters', async () => {
const mockGridCharacters: GridCharacter[] = [
{
id: 'gc-1',
position: 1,
uncapLevel: 5,
transcendenceStage: 1,
character: {
id: 'char-1',
granblueId: 'c-1',
name: { en: 'Katalina' },
element: 2,
rarity: 5
}
}
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
grid_characters: mockGridCharacters.map(gc => ({
...gc,
uncap_level: gc.uncapLevel,
transcendence_stage: gc.transcendenceStage
}))
})
})
const result = await adapter.updateGridCharacters({
shortcode: 'ABC123',
updates: [
{
position: 1,
characterId: 'char-1',
uncapLevel: 5,
transcendenceStage: 1
}
]
})
expect(result.gridCharacters).toEqual(mockGridCharacters)
})
it('should handle grid conflicts', async () => {
const conflictResponse = {
grid_weapons: [],
conflicts: {
conflicts: [
{
type: 'weapon',
position: 1,
existing: { id: 'weapon-1' },
new: { id: 'weapon-2' }
}
],
resolved: false
}
}
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => conflictResponse
json: async () => mockResponse
})
const result = await adapter.updateGridWeapons({
shortcode: 'ABC123',
updates: [
{
position: 1,
weaponId: 'weapon-2'
}
]
})
const operations = [
{
type: 'move' as const,
entity: 'weapon' as const,
id: 'gw-1',
position: 2
},
{
type: 'swap' as const,
entity: 'character' as const,
sourceId: 'gc-1',
targetId: 'gc-2'
}
]
expect(result.conflicts).toBeDefined()
expect(result.conflicts?.resolved).toBe(false)
expect(result.conflicts?.conflicts).toHaveLength(1)
const result = await adapter.gridUpdate(
'ABC123',
operations,
{ maintainCharacterSequence: true }
)
expect(result.operationsApplied).toBe(2)
expect(result.changes).toHaveLength(2)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123/grid_update',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
operations: [
{
type: 'move',
entity: 'weapon',
id: 'gw-1',
position: 2
},
{
type: 'swap',
entity: 'character',
source_id: 'gc-1',
target_id: 'gc-2'
}
],
options: { maintain_character_sequence: true }
})
})
)
})
})
@ -394,17 +284,7 @@ describe('PartyAdapter', () => {
job: {
id: 'job-2',
name: { en: 'Mage' },
skills: [
{
id: 'skill-2',
name: { en: 'Fireball' },
slot: 1
}
],
accessory: {
id: 'acc-1',
name: { en: 'Magic Ring' }
}
skills: []
}
}
@ -413,24 +293,55 @@ describe('PartyAdapter', () => {
json: async () => updatedParty
})
const result = await adapter.updateJob(
const result = await adapter.updateJob('ABC123', 'job-2')
expect(result).toEqual(updatedParty)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123/jobs',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
job_id: 'job-2'
})
})
)
})
it('should update job skills', async () => {
const updatedParty = {
...mockParty,
job: {
...mockParty.job!,
skills: [
{ id: 'skill-1', name: { en: 'Rage' }, slot: 1 },
{ id: 'skill-2', name: { en: 'Heal' }, slot: 2 }
]
}
}
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => updatedParty
})
const result = await adapter.updateJobSkills(
'ABC123',
'job-2',
[{ id: 'skill-2', slot: 1 }],
'acc-1'
[
{ id: 'skill-1', slot: 1 },
{ id: 'skill-2', slot: 2 }
]
)
expect(result).toEqual(updatedParty)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123',
'https://api.example.com/parties/ABC123/job_skills',
expect.objectContaining({
method: 'PATCH',
method: 'PUT',
body: JSON.stringify({
party: {
job_id: 'job-2',
job_skills_attributes: [{ id: 'skill-2', slot: 1 }],
job_accessory_id: 'acc-1'
}
skills: [
{ id: 'skill-1', slot: 1 },
{ id: 'skill-2', slot: 2 }
]
})
})
)

View file

@ -25,11 +25,9 @@ export type {
CreatePartyParams,
UpdatePartyParams,
ListUserPartiesParams,
UpdateGridParams,
GridWeaponUpdate,
GridSummonUpdate,
GridCharacterUpdate,
ConflictResolution
GridOperation,
GridUpdateOptions,
GridUpdateResponse
} from './party.adapter'
// export { GridAdapter } from './grid.adapter'

View file

@ -153,70 +153,40 @@ export interface ListUserPartiesParams {
}
/**
* Parameters for updating grid items
* Grid operation for batch updates
*/
export interface UpdateGridParams<T> {
shortcode: string
updates: T[]
export interface GridOperation {
type: 'move' | 'swap' | 'remove'
entity: 'weapon' | 'character' | 'summon'
id?: string
sourceId?: string
targetId?: string
position?: number
container?: string
}
/**
* Grid weapon update structure
* Options for grid update operation
*/
export interface GridWeaponUpdate {
id?: string
position: number
weaponId: string
mainhand?: boolean
uncapLevel?: number
transcendenceStage?: number
weaponKeys?: Array<{
export interface GridUpdateOptions {
maintainCharacterSequence?: boolean
validateBeforeExecute?: boolean
}
/**
* Response from grid update operation
*/
export interface GridUpdateResponse {
party: Party
operationsApplied: number
changes: Array<{
entity: string
id: string
slot: number
action: string
from?: number
to?: number
with?: string
}>
_destroy?: boolean
}
/**
* Grid summon update structure
*/
export interface GridSummonUpdate {
id?: string
position: number
summonId: string
quickSummon?: boolean
transcendenceStage?: number
_destroy?: boolean
}
/**
* Grid character update structure
*/
export interface GridCharacterUpdate {
id?: string
position: number
characterId: string
uncapLevel?: number
transcendenceStage?: number
perpetualModifiers?: Record<string, any>
awakenings?: Array<{
id: string
level: number
}>
_destroy?: boolean
}
/**
* Conflict resolution result
*/
export interface ConflictResolution {
conflicts: Array<{
type: 'weapon' | 'summon' | 'character'
position: number
existing: any
new: any
}>
resolved: boolean
}
/**
@ -295,46 +265,19 @@ export class PartyAdapter extends BaseAdapter {
}
/**
* Updates grid weapons for a party
* Performs atomic batch grid updates
* Supports move, swap, and remove operations on grid items
*/
async updateGridWeapons(
params: UpdateGridParams<GridWeaponUpdate>
): Promise<{ gridWeapons: GridWeapon[]; conflicts?: ConflictResolution }> {
const { shortcode, updates } = params
return this.request(`/parties/${shortcode}/grid_weapons`, {
method: 'PATCH',
async gridUpdate(
shortcode: string,
operations: GridOperation[],
options?: GridUpdateOptions
): Promise<GridUpdateResponse> {
return this.request(`/parties/${shortcode}/grid_update`, {
method: 'POST',
body: {
grid_weapons: updates
}
})
}
/**
* Updates grid summons for a party
*/
async updateGridSummons(
params: UpdateGridParams<GridSummonUpdate>
): Promise<{ gridSummons: GridSummon[]; conflicts?: ConflictResolution }> {
const { shortcode, updates } = params
return this.request(`/parties/${shortcode}/grid_summons`, {
method: 'PATCH',
body: {
grid_summons: updates
}
})
}
/**
* Updates grid characters for a party
*/
async updateGridCharacters(
params: UpdateGridParams<GridCharacterUpdate>
): Promise<{ gridCharacters: GridCharacter[]; conflicts?: ConflictResolution }> {
const { shortcode, updates } = params
return this.request(`/parties/${shortcode}/grid_characters`, {
method: 'PATCH',
body: {
grid_characters: updates
operations,
options
}
})
}
@ -344,22 +287,80 @@ export class PartyAdapter extends BaseAdapter {
*/
async updateJob(
shortcode: string,
jobId: string,
skills?: Array<{ id: string; slot: number }>,
accessoryId?: string
jobId: string
): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}`, {
method: 'PATCH',
return this.request<Party>(`/parties/${shortcode}/jobs`, {
method: 'PUT',
body: {
party: {
job_id: jobId,
...(skills && { job_skills_attributes: skills }),
...(accessoryId && { job_accessory_id: accessoryId })
}
job_id: jobId
}
})
}
/**
* Updates job skills for a party
*/
async updateJobSkills(
shortcode: string,
skills: Array<{ id: string; slot: number }>
): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/job_skills`, {
method: 'PUT',
body: {
skills
}
})
}
/**
* Removes a job skill from a party
*/
async removeJobSkill(
shortcode: string,
skillSlot: number
): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/job_skills`, {
method: 'DELETE',
body: {
slot: skillSlot
}
})
}
/**
* Gets party preview image
*/
async getPreview(shortcode: string): Promise<Blob> {
return this.request<Blob>(`/parties/${shortcode}/preview`, {
method: 'GET',
headers: {
'Accept': 'image/png'
}
})
}
/**
* Gets party preview status
*/
async getPreviewStatus(shortcode: string): Promise<{
state: string
generatedAt?: string
readyForPreview: boolean
}> {
return this.request(`/parties/${shortcode}/preview_status`, {
method: 'GET'
})
}
/**
* Regenerates party preview
*/
async regeneratePreview(shortcode: string): Promise<{ status: string }> {
return this.request(`/parties/${shortcode}/regenerate_preview`, {
method: 'POST'
})
}
/**
* Clears the cache for party-related data
*/

View file

@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { toEditData, toPayload, CharacterEditSchema } from './schema'
describe('characters/schema', () => {
const model = {
name: { en: 'Narmaya' },
granblue_id: '3040109000',
rarity: 4,
element: 6,
race: [2, null],
gender: 2,
proficiency: [1, 5],
hp: { min_hp: 200, max_hp: 1500, max_hp_flb: 1600 },
atk: { min_atk: 800, max_atk: 7200, max_atk_flb: 7400 },
uncap: { flb: true, ulb: false, transcendence: false },
special: false
}
it('toEditData maps model to edit state', () => {
const edit = toEditData(model)
expect(edit.granblue_id).toBe('3040109000')
expect(edit.race1).toBe(2)
expect(edit.race2).toBeNull()
expect(edit.flb).toBe(true)
})
it('toPayload maps edit state to API payload', () => {
const edit = toEditData(model)
const payload = toPayload(edit)
expect(payload.race).toEqual([2])
expect(payload.uncap.flb).toBe(true)
})
it('CharacterEditSchema validates a correct edit state', () => {
const edit = toEditData(model)
const parsed = CharacterEditSchema.parse(edit)
expect(parsed.granblue_id).toBe('3040109000')
})
it('CharacterEditSchema rejects invalid edit state', () => {
const bad = { ...toEditData(model), granblue_id: '' }
const res = CharacterEditSchema.safeParse(bad)
expect(res.success).toBe(false)
})
})

View file

@ -0,0 +1,80 @@
import { z } from 'zod'
// Edit-state schema used on the client and for form validation server-side
export const CharacterEditSchema = z.object({
name: z.union([z.string(), z.object({ en: z.string().optional(), ja: z.string().optional() })]).optional(),
granblue_id: z.string().min(1),
rarity: z.number().int().min(1),
element: z.number().int().min(0),
race1: z.number().int().nullable().optional(),
race2: z.number().int().nullable().optional(),
gender: z.number().int().min(0),
proficiency1: z.number().int().min(0),
proficiency2: z.number().int().min(0),
min_hp: z.number().int().min(0),
max_hp: z.number().int().min(0),
max_hp_flb: z.number().int().min(0),
min_atk: z.number().int().min(0),
max_atk: z.number().int().min(0),
max_atk_flb: z.number().int().min(0),
flb: z.boolean(),
ulb: z.boolean(),
transcendence: z.boolean(),
special: z.boolean()
})
export type CharacterEdit = z.infer<typeof CharacterEditSchema>
export function toEditData(model: any): CharacterEdit {
return {
name: model?.name ?? '',
granblue_id: model?.granblue_id ?? '',
rarity: model?.rarity ?? 1,
element: model?.element ?? 0,
race1: model?.race?.[0] ?? null,
race2: model?.race?.[1] ?? null,
gender: model?.gender ?? 0,
proficiency1: model?.proficiency?.[0] ?? 0,
proficiency2: model?.proficiency?.[1] ?? 0,
min_hp: model?.hp?.min_hp ?? 0,
max_hp: model?.hp?.max_hp ?? 0,
max_hp_flb: model?.hp?.max_hp_flb ?? 0,
min_atk: model?.atk?.min_atk ?? 0,
max_atk: model?.atk?.max_atk ?? 0,
max_atk_flb: model?.atk?.max_atk_flb ?? 0,
flb: model?.uncap?.flb ?? false,
ulb: model?.uncap?.ulb ?? false,
transcendence: model?.uncap?.transcendence ?? false,
special: model?.special ?? false
}
}
// Payload mapping to backend API
export function toPayload(edit: CharacterEdit) {
return {
name: edit.name,
granblue_id: edit.granblue_id,
rarity: edit.rarity,
element: edit.element,
race: [edit.race1, edit.race2].filter((r) => r !== null && r !== undefined),
gender: edit.gender,
proficiency: [edit.proficiency1, edit.proficiency2],
hp: {
min_hp: edit.min_hp,
max_hp: edit.max_hp,
max_hp_flb: edit.max_hp_flb
},
atk: {
min_atk: edit.min_atk,
max_atk: edit.max_atk,
max_atk_flb: edit.max_atk_flb
},
uncap: {
flb: edit.flb,
ulb: edit.ulb,
transcendence: edit.transcendence
},
special: edit.special
}
}

View file

@ -0,0 +1,26 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
const rarityOptions = getRarityOptions()
</script>
<DetailsContainer title="Metadata">
{#if editMode}
<DetailItem label="Rarity" bind:value={editData.rarity} editable={true} type="select" options={rarityOptions} />
<DetailItem label="Granblue ID" bind:value={editData.granblue_id} editable={true} type="text" />
{:else}
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
<DetailItem label="Granblue ID" value={character.granblue_id} />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,43 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(character?.uncap?.flb))
</script>
<DetailsContainer title="HP Stats">
{#if editMode}
<DetailItem label="Base HP" bind:value={editData.min_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP" bind:value={editData.max_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP (FLB)" bind:value={editData.max_hp_flb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base HP" value={character.hp?.min_hp} />
<DetailItem label="Max HP" value={character.hp?.max_hp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={character.hp?.max_hp_flb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
{#if editMode}
<DetailItem label="Base Attack" bind:value={editData.min_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack" bind:value={editData.max_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack (FLB)" bind:value={editData.max_atk_flb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base Attack" value={character.atk?.min_atk} />
<DetailItem label="Max Attack" value={character.atk?.max_atk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={character.atk?.max_atk_flb} />
{/if}
{/if}
</DetailsContainer>

View file

@ -0,0 +1,42 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element'
import { getRaceLabel, getRaceOptions } from '$lib/utils/race'
import { getGenderLabel, getGenderOptions } from '$lib/utils/gender'
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
const elementOptions = getElementOptions()
const raceOptions = getRaceOptions()
const genderOptions = getGenderOptions()
const proficiencyOptions = getProficiencyOptions()
</script>
<DetailsContainer title="Details">
{#if editMode}
<DetailItem label="Element" bind:value={editData.element} editable={true} type="select" options={elementOptions} />
<DetailItem label="Race 1" bind:value={editData.race1} editable={true} type="select" options={raceOptions} />
<DetailItem label="Race 2" bind:value={editData.race2} editable={true} type="select" options={raceOptions} />
<DetailItem label="Gender" bind:value={editData.gender} editable={true} type="select" options={genderOptions} />
<DetailItem label="Proficiency 1" bind:value={editData.proficiency1} editable={true} type="select" options={proficiencyOptions} />
<DetailItem label="Proficiency 2" bind:value={editData.proficiency2} editable={true} type="select" options={proficiencyOptions} />
{:else}
<DetailItem label="Element" value={getElementLabel(character.element)} />
<DetailItem label="Race 1" value={getRaceLabel(character.race?.[0])} />
{#if character.race?.[1]}
<DetailItem label="Race 2" value={getRaceLabel(character.race?.[1])} />
{/if}
<DetailItem label="Gender" value={getGenderLabel(character.gender)} />
<DetailItem label="Proficiency 1" value={getProficiencyLabel(character.proficiency[0])} />
<DetailItem label="Proficiency 2" value={getProficiencyLabel(character.proficiency[1])} />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,51 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getCharacterMaxUncapLevel } from '$lib/utils/uncap'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
const uncap = $derived(
editMode
? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence }
: (character?.uncap ?? {})
)
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
const special = $derived(editMode ? editData.special : (character?.special ?? false))
const uncapLevel = $derived(getCharacterMaxUncapLevel({ special, uncap }))
const transcendenceStage = $derived(transcendence ? 5 : 0)
</script>
<DetailsContainer title="Details">
{#if character.uncap}
<DetailItem label="Uncap">
<UncapIndicator
type="character"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
{special}
editable={false}
/>
</DetailItem>
{/if}
{#if editMode}
<DetailItem label="FLB" bind:value={editData.flb} editable={true} type="checkbox" />
<DetailItem label="ULB" bind:value={editData.ulb} editable={true} type="checkbox" />
<DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" />
<DetailItem label="Special" bind:value={editData.special} editable={true} type="checkbox" />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,104 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
import type { Snippet } from 'svelte'
interface Props {
type: 'character' | 'summon' | 'weapon'
item: any
image: string
showEdit?: boolean
editMode?: boolean
isSaving?: boolean
saveSuccess?: boolean
saveError?: string | null
onEdit?: () => void
onSave?: () => void
onCancel?: () => void
}
let {
type,
item,
image,
showEdit = false,
editMode = false,
isSaving = false,
saveSuccess = false,
saveError = null,
onEdit,
onSave,
onCancel,
children
}: Props & { children: Snippet } = $props()
</script>
<div class="content">
<DetailsHeader
{type}
{item}
{image}
{editMode}
showEdit={showEdit}
onEdit={onEdit}
onSave={onSave}
onCancel={onCancel}
{isSaving}
/>
{#if saveSuccess || saveError}
<div class="edit-controls">
{#if saveSuccess}
<span class="success-message">Changes saved successfully!</span>
{/if}
{#if saveError}
<span class="error-message">{saveError}</span>
{/if}
</div>
{/if}
{@render children?.()}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/effects' as effects;
.content {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: visible;
margin-top: spacing.$unit-2x;
position: relative;
}
.edit-controls {
padding: spacing.$unit-2x;
border-bottom: 1px solid colors.$grey-80;
display: flex;
gap: spacing.$unit;
align-items: center;
.success-message {
color: colors.$grey-30;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View file

@ -0,0 +1,14 @@
export type ToEditData<TModel, TEdit> = (model: TModel) => TEdit
export interface EditForm<TModel, TEdit> {
init: (model: TModel) => TEdit
reset: (model: TModel) => TEdit
}
export function createEditForm<TModel, TEdit>(toEditData: ToEditData<TModel, TEdit>): EditForm<TModel, TEdit> {
return {
init: (model: TModel) => toEditData(model),
reset: (model: TModel) => toEditData(model),
}
}

View file

@ -0,0 +1,56 @@
export type ResourceKind = 'character' | 'weapon' | 'summon'
export type ImageVariant = 'main' | 'grid' | 'square'
function folder(type: ResourceKind, variant: ImageVariant) {
// Folders are kebab-case: e.g. weapon-main, summon-grid
if (type === 'character') {
if (variant === 'main') return 'character-main'
if (variant === 'grid') return 'character-grid'
return 'character-square'
}
return `${type}-${variant}` // weapon-main, summon-grid, weapon-square, etc.
}
export function getPlaceholder(type: ResourceKind, variant: ImageVariant): string {
// Try specific placeholder; fall back to -main if others are unavailable
const specific = `/images/placeholders/placeholder-${type}-${variant}.png`
if (variant !== 'main') return specific
return specific
}
interface ImageArgs {
type: ResourceKind
id?: string | null
variant: ImageVariant
element?: number // only used for weapon grid element-specific variants
pose?: string // character pose suffix like '01', '02'
}
export function getImageUrl({ type, id, variant, element, pose }: ImageArgs): string {
if (!id) return getPlaceholder(type, variant)
const base = `/images/${folder(type, variant)}`
if (type === 'character') {
// Characters include pose suffix in filenames; default to 01
const suffix = `_${pose || '01'}`
return `${base}/${id}${suffix}.jpg`
}
if (type === 'weapon' && variant === 'grid' && element && element > 0) {
// Support element-specific grid images when provided
return `${base}/${id}_${element}.jpg`
}
return `${base}/${id}.jpg`
}
// Convenience wrappers
export const getCharacterImage = (id?: string | null, pose?: string, variant: ImageVariant = 'main') =>
getImageUrl({ type: 'character', id: id ?? undefined, variant, pose })
export const getWeaponImage = (id?: string | null, variant: ImageVariant = 'main', element?: number) =>
getImageUrl({ type: 'weapon', id: id ?? undefined, variant, element })
export const getSummonImage = (id?: string | null, variant: ImageVariant = 'main') =>
getImageUrl({ type: 'summon', id: id ?? undefined, variant })

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { toEditData, toPayload, SummonEditSchema } from './schema'
describe('summons/schema', () => {
const model = {
name: { en: 'Bahamut' },
granblue_id: '2040004000',
rarity: 4,
element: 5,
hp: { min_hp: 100, max_hp: 900, max_hp_flb: 1000 },
atk: { min_atk: 300, max_atk: 2400, max_atk_flb: 2600 },
uncap: { flb: true, ulb: true, transcendence: false }
}
it('toEditData maps model to edit state', () => {
const edit = toEditData(model)
expect(edit.granblue_id).toBe('2040004000')
expect(edit.flb).toBe(true)
expect(edit.ulb).toBe(true)
})
it('toPayload maps edit state to API payload', () => {
const edit = toEditData(model)
const payload = toPayload(edit)
expect(payload.uncap.ulb).toBe(true)
})
it('SummonEditSchema validates a correct edit state', () => {
const edit = toEditData(model)
const parsed = SummonEditSchema.parse(edit)
expect(parsed.granblue_id).toBe('2040004000')
})
it('SummonEditSchema rejects invalid edit state', () => {
const bad = { ...toEditData(model), granblue_id: '' }
const res = SummonEditSchema.safeParse(bad)
expect(res.success).toBe(false)
})
})

View file

@ -0,0 +1,62 @@
import { z } from 'zod'
export const SummonEditSchema = z.object({
name: z.union([z.string(), z.object({ en: z.string().optional(), ja: z.string().optional() })]).optional(),
granblue_id: z.string().min(1),
rarity: z.number().int().min(1),
element: z.number().int().min(0),
min_hp: z.number().int().min(0),
max_hp: z.number().int().min(0),
max_hp_flb: z.number().int().min(0),
min_atk: z.number().int().min(0),
max_atk: z.number().int().min(0),
max_atk_flb: z.number().int().min(0),
flb: z.boolean(),
ulb: z.boolean(),
transcendence: z.boolean()
})
export type SummonEdit = z.infer<typeof SummonEditSchema>
export function toEditData(model: any): SummonEdit {
return {
name: model?.name ?? '',
granblue_id: model?.granblue_id ?? '',
rarity: model?.rarity ?? 1,
element: model?.element ?? 0,
min_hp: model?.hp?.min_hp ?? 0,
max_hp: model?.hp?.max_hp ?? 0,
max_hp_flb: model?.hp?.max_hp_flb ?? 0,
min_atk: model?.atk?.min_atk ?? 0,
max_atk: model?.atk?.max_atk ?? 0,
max_atk_flb: model?.atk?.max_atk_flb ?? 0,
flb: model?.uncap?.flb ?? false,
ulb: model?.uncap?.ulb ?? false,
transcendence: model?.uncap?.transcendence ?? false
}
}
export function toPayload(edit: SummonEdit) {
return {
name: edit.name,
granblue_id: edit.granblue_id,
rarity: edit.rarity,
element: edit.element,
hp: {
min_hp: edit.min_hp,
max_hp: edit.max_hp,
max_hp_flb: edit.max_hp_flb
},
atk: {
min_atk: edit.min_atk,
max_atk: edit.max_atk,
max_atk_flb: edit.max_atk_flb
},
uncap: {
flb: edit.flb,
ulb: edit.ulb,
transcendence: edit.transcendence
}
}
}

View file

@ -0,0 +1,23 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
let { summon, editMode = false, editData = $bindable<any>() }:
{ summon: any; editMode?: boolean; editData?: any } = $props()
const rarityOptions = getRarityOptions()
</script>
<DetailsContainer title="Metadata">
{#if editMode}
<DetailItem label="Rarity" bind:value={editData.rarity} editable={true} type="select" options={rarityOptions} />
<DetailItem label="Granblue ID" bind:value={editData.granblue_id} editable={true} type="text" />
{:else}
<DetailItem label="Rarity" value={getRarityLabel(summon.rarity)} />
<DetailItem label="Granblue ID" value={summon.granblue_id} />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,40 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
let { summon, editMode = false, editData = $bindable<any>() }:
{ summon: any; editMode?: boolean; editData?: any } = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(summon?.uncap?.flb))
</script>
<DetailsContainer title="HP Stats">
{#if editMode}
<DetailItem label="Base HP" bind:value={editData.min_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP" bind:value={editData.max_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP (FLB)" bind:value={editData.max_hp_flb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base HP" value={summon.hp?.min_hp} />
<DetailItem label="Max HP" value={summon.hp?.max_hp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={summon.hp?.max_hp_flb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
{#if editMode}
<DetailItem label="Base Attack" bind:value={editData.min_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack" bind:value={editData.max_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack (FLB)" bind:value={editData.max_atk_flb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base Attack" value={summon.atk?.min_atk} />
<DetailItem label="Max Attack" value={summon.atk?.max_atk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={summon.atk?.max_atk_flb} />
{/if}
{/if}
</DetailsContainer>

View file

@ -0,0 +1,21 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element'
let { summon, editMode = false, editData = $bindable<any>() }:
{ summon: any; editMode?: boolean; editData?: any } = $props()
const elementOptions = getElementOptions()
</script>
<DetailsContainer title="Details">
{#if editMode}
<DetailItem label="Element" bind:value={editData.element} editable={true} type="select" options={elementOptions} />
{:else}
<DetailItem label="Element" value={getElementLabel(summon.element)} />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,27 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
let { summon, editMode = false, editData = $bindable<any>() }:
{ summon: any; editMode?: boolean; editData?: any } = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(summon?.uncap?.flb))
const ulb = $derived(editMode ? Boolean(editData.ulb) : Boolean(summon?.uncap?.ulb))
const transcendence = $derived(editMode ? Boolean(editData.transcendence) : Boolean(summon?.uncap?.transcendence))
</script>
<DetailsContainer title="Uncap">
<DetailItem label="Uncap">
<UncapIndicator type="summon" {flb} {ulb} {transcendence} editable={false} />
</DetailItem>
{#if editMode}
<DetailItem label="FLB" bind:value={editData.flb} editable={true} type="checkbox" />
<DetailItem label="ULB" bind:value={editData.ulb} editable={true} type="checkbox" />
<DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { toEditData, toPayload, WeaponEditSchema } from './schema'
describe('weapons/schema', () => {
const model = {
name: { en: 'Indra Sword' },
granblue_id: '1040000000',
rarity: 4,
element: 6,
proficiency: [1, 2],
hp: { min_hp: 30, max_hp: 230, max_hp_flb: 260 },
atk: { min_atk: 380, max_atk: 2840, max_atk_flb: 3000 },
uncap: { flb: true, ulb: false, transcendence: false }
}
it('toEditData maps model to edit state', () => {
const edit = toEditData(model)
expect(edit.granblue_id).toBe('1040000000')
expect(edit.proficiency1).toBe(1)
expect(edit.proficiency2).toBe(2)
expect(edit.flb).toBe(true)
})
it('toPayload maps edit state to API payload', () => {
const edit = toEditData(model)
const payload = toPayload(edit)
expect(payload.proficiency).toEqual([1, 2])
expect(payload.uncap.flb).toBe(true)
})
it('WeaponEditSchema validates a correct edit state', () => {
const edit = toEditData(model)
const parsed = WeaponEditSchema.parse(edit)
expect(parsed.granblue_id).toBe('1040000000')
})
it('WeaponEditSchema rejects invalid edit state', () => {
const bad = { ...toEditData(model), granblue_id: '' }
const res = WeaponEditSchema.safeParse(bad)
expect(res.success).toBe(false)
})
})

View file

@ -0,0 +1,67 @@
import { z } from 'zod'
export const WeaponEditSchema = z.object({
name: z.union([z.string(), z.object({ en: z.string().optional(), ja: z.string().optional() })]).optional(),
granblue_id: z.string().min(1),
rarity: z.number().int().min(1),
element: z.number().int().min(0),
proficiency1: z.number().int().min(0).optional().default(0),
proficiency2: z.number().int().min(0).optional().default(0),
min_hp: z.number().int().min(0),
max_hp: z.number().int().min(0),
max_hp_flb: z.number().int().min(0),
min_atk: z.number().int().min(0),
max_atk: z.number().int().min(0),
max_atk_flb: z.number().int().min(0),
flb: z.boolean(),
ulb: z.boolean(),
transcendence: z.boolean()
})
export type WeaponEdit = z.infer<typeof WeaponEditSchema>
export function toEditData(model: any): WeaponEdit {
return {
name: model?.name ?? '',
granblue_id: model?.granblue_id ?? '',
rarity: model?.rarity ?? 1,
element: model?.element ?? 0,
proficiency1: Array.isArray(model?.proficiency) ? (model.proficiency[0] ?? 0) : (model?.proficiency ?? 0),
proficiency2: Array.isArray(model?.proficiency) ? (model.proficiency[1] ?? 0) : 0,
min_hp: model?.hp?.min_hp ?? 0,
max_hp: model?.hp?.max_hp ?? 0,
max_hp_flb: model?.hp?.max_hp_flb ?? 0,
min_atk: model?.atk?.min_atk ?? 0,
max_atk: model?.atk?.max_atk ?? 0,
max_atk_flb: model?.atk?.max_atk_flb ?? 0,
flb: model?.uncap?.flb ?? false,
ulb: model?.uncap?.ulb ?? false,
transcendence: model?.uncap?.transcendence ?? false
}
}
export function toPayload(edit: WeaponEdit) {
return {
name: edit.name,
granblue_id: edit.granblue_id,
rarity: edit.rarity,
element: edit.element,
proficiency: [edit.proficiency1, edit.proficiency2].filter(v => v !== 0),
hp: {
min_hp: edit.min_hp,
max_hp: edit.max_hp,
max_hp_flb: edit.max_hp_flb
},
atk: {
min_atk: edit.min_atk,
max_atk: edit.max_atk,
max_atk_flb: edit.max_atk_flb
},
uncap: {
flb: edit.flb,
ulb: edit.ulb,
transcendence: edit.transcendence
}
}
}

View file

@ -0,0 +1,23 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
let { weapon, editMode = false, editData = $bindable<any>() }:
{ weapon: any; editMode?: boolean; editData?: any } = $props()
const rarityOptions = getRarityOptions()
</script>
<DetailsContainer title="Metadata">
{#if editMode}
<DetailItem label="Rarity" bind:value={editData.rarity} editable={true} type="select" options={rarityOptions} />
<DetailItem label="Granblue ID" bind:value={editData.granblue_id} editable={true} type="text" />
{:else}
<DetailItem label="Rarity" value={getRarityLabel(weapon.rarity)} />
<DetailItem label="Granblue ID" value={weapon.granblue_id} />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,39 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
let { weapon, editMode = false, editData = $bindable<any>() }:
{ weapon: any; editMode?: boolean; editData?: any } = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(weapon?.uncap?.flb))
</script>
<DetailsContainer title="HP Stats">
{#if editMode}
<DetailItem label="Base HP" bind:value={editData.min_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP" bind:value={editData.max_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP (FLB)" bind:value={editData.max_hp_flb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base HP" value={weapon.hp?.min_hp} />
<DetailItem label="Max HP" value={weapon.hp?.max_hp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={weapon.hp?.max_hp_flb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
{#if editMode}
<DetailItem label="Base Attack" bind:value={editData.min_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack" bind:value={editData.max_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack (FLB)" bind:value={editData.max_atk_flb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base Attack" value={weapon.atk?.min_atk} />
<DetailItem label="Max Attack" value={weapon.atk?.max_atk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={weapon.atk?.max_atk_flb} />
{/if}
{/if}
</DetailsContainer>

View file

@ -0,0 +1,29 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element'
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
let { weapon, editMode = false, editData = $bindable<any>() }:
{ weapon: any; editMode?: boolean; editData?: any } = $props()
const elementOptions = getElementOptions()
const proficiencyOptions = getProficiencyOptions()
</script>
<DetailsContainer title="Details">
{#if editMode}
<DetailItem label="Element" bind:value={editData.element} editable={true} type="select" options={elementOptions} />
<DetailItem label="Proficiency 1" bind:value={editData.proficiency1} editable={true} type="select" options={proficiencyOptions} />
<DetailItem label="Proficiency 2" bind:value={editData.proficiency2} editable={true} type="select" options={proficiencyOptions} />
{:else}
<DetailItem label="Element" value={getElementLabel(weapon.element)} />
<DetailItem label="Proficiency 1" value={getProficiencyLabel(Array.isArray(weapon.proficiency) ? weapon.proficiency[0] : weapon.proficiency)} />
{#if Array.isArray(weapon.proficiency) && weapon.proficiency[1] !== undefined}
<DetailItem label="Proficiency 2" value={getProficiencyLabel(weapon.proficiency[1])} />
{/if}
{/if}
</DetailsContainer>

View file

@ -0,0 +1,27 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
let { weapon, editMode = false, editData = $bindable<any>() }:
{ weapon: any; editMode?: boolean; editData?: any } = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(weapon?.uncap?.flb))
const ulb = $derived(editMode ? Boolean(editData.ulb) : Boolean(weapon?.uncap?.ulb))
const transcendence = $derived(editMode ? Boolean(editData.transcendence) : Boolean(weapon?.uncap?.transcendence))
</script>
<DetailsContainer title="Uncap">
<DetailItem label="Uncap">
<UncapIndicator type="weapon" {flb} {ulb} {transcendence} editable={false} />
</DetailItem>
{#if editMode}
<DetailItem label="FLB" bind:value={editData.flb} editable={true} type="checkbox" />
<DetailItem label="ULB" bind:value={editData.ulb} editable={true} type="checkbox" />
<DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" />
{/if}
</DetailsContainer>

View file

@ -0,0 +1,31 @@
import type { FetchLike } from '$lib/api/core'
import { get } from '$lib/api/core'
import { error } from '@sveltejs/kit'
export type Resource = 'characters' | 'weapons' | 'summons'
function singular(type: Resource): 'character' | 'weapon' | 'summon' {
if (type === 'characters') return 'character'
if (type === 'weapons') return 'weapon'
return 'summon'
}
export async function getResourceDetail(fetch: FetchLike, type: Resource, id: string, normalize?: (m: any) => any) {
try {
const item = await get<any>(fetch, `/${type}/${id}`)
if (!item) throw error(404, 'Not found')
return normalize ? normalize(item) : item
} catch (e: any) {
// Map HTTP 404 from API client into SvelteKit 404
if (e?.message?.includes('HTTP 404')) throw error(404, 'Not found')
throw error(500, `Failed to load ${singular(type)}`)
}
}
export const createDetailLoader = (type: Resource, normalize?: (m: any) => any) =>
async ({ params, fetch, parent }: { params: { id: string }; fetch: FetchLike; parent: () => Promise<any> }) => {
const { role } = await parent()
const item = await getResourceDetail(fetch, type, params.id, normalize)
return { [singular(type)]: item, role }
}

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest'
import { actions } from './+page.server'
import { toEditData } from '$lib/features/database/characters/schema'
function makeEvent(edit: any, opts?: { status?: number }) {
const form = new FormData()
form.set('payload', JSON.stringify(edit))
const request = { formData: async () => form } as unknown as Request
const status = opts?.status ?? 200
const fetch = vi.fn(async () => new Response('{}', { status }))
const params = { id: '3040109000' } as any
return { request, fetch, params } as any
}
describe('characters actions.save', () => {
it('succeeds on valid payload', async () => {
const edit = toEditData({ granblue_id: '3040109000' })
const res: any = await actions.save!(makeEvent(edit))
expect(res).toMatchObject({ success: true })
})
it('fails validation for bad payload', async () => {
const edit = { ...toEditData({ granblue_id: 'x' }), granblue_id: '' }
const res: any = await actions.save!(makeEvent(edit))
expect(res.status).toBe(422)
expect(res.data.message).toBe('Validation error')
})
it('handles backend error', async () => {
const edit = toEditData({ granblue_id: '3040109000' })
const res: any = await actions.save!(makeEvent(edit, { status: 500 }))
expect(res.status).toBe(500)
})
})

View file

@ -0,0 +1,35 @@
import { describe, it, expect, vi } from 'vitest'
import { actions } from './+page.server'
import { toEditData } from '$lib/features/database/summons/schema'
function makeEvent(edit: any, opts?: { status?: number }) {
const form = new FormData()
form.set('payload', JSON.stringify(edit))
const request = { formData: async () => form } as unknown as Request
const status = opts?.status ?? 200
const fetch = vi.fn(async () => new Response('{}', { status }))
const params = { id: '2040004000' } as any
return { request, fetch, params } as any
}
describe('summons actions.save', () => {
it('succeeds on valid payload', async () => {
const edit = toEditData({ granblue_id: '2040004000' })
const res: any = await actions.save!(makeEvent(edit))
expect(res).toMatchObject({ success: true })
})
it('fails validation for bad payload', async () => {
const edit = { ...toEditData({ granblue_id: 'x' }), granblue_id: '' }
const res: any = await actions.save!(makeEvent(edit))
expect(res.status).toBe(422)
expect(res.data.message).toBe('Validation error')
})
it('handles backend error', async () => {
const edit = toEditData({ granblue_id: '2040004000' })
const res: any = await actions.save!(makeEvent(edit, { status: 500 }))
expect(res.status).toBe(500)
})
})

View file

@ -0,0 +1,35 @@
import { describe, it, expect, vi } from 'vitest'
import { actions } from './+page.server'
import { toEditData } from '$lib/features/database/weapons/schema'
function makeEvent(edit: any, opts?: { status?: number }) {
const form = new FormData()
form.set('payload', JSON.stringify(edit))
const request = { formData: async () => form } as unknown as Request
const status = opts?.status ?? 200
const fetch = vi.fn(async () => new Response('{}', { status }))
const params = { id: '1040000000' } as any
return { request, fetch, params } as any
}
describe('weapons actions.save', () => {
it('succeeds on valid payload', async () => {
const edit = toEditData({ granblue_id: '1040000000' })
const res: any = await actions.save!(makeEvent(edit))
expect(res).toMatchObject({ success: true })
})
it('fails validation for bad payload', async () => {
const edit = { ...toEditData({ granblue_id: 'x' }), granblue_id: '' }
const res: any = await actions.save!(makeEvent(edit))
expect(res.status).toBe(422)
expect(res.data.message).toBe('Validation error')
})
it('handles backend error', async () => {
const edit = toEditData({ granblue_id: '1040000000' })
const res: any = await actions.save!(makeEvent(edit, { status: 500 }))
expect(res.status).toBe(500)
})
})