From 4f8beab3eaff92fa3f41e98b7a8ed280f93db73a Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sat, 20 Sep 2025 00:04:36 -0700 Subject: [PATCH] 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 --- .../adapters/__tests__/party.adapter.test.ts | 301 ++++++------------ src/lib/api/adapters/index.ts | 8 +- src/lib/api/adapters/party.adapter.ts | 211 ++++++------ .../database/characters/schema.test.ts | 46 +++ .../features/database/characters/schema.ts | 80 +++++ .../sections/CharacterMetadataSection.svelte | 26 ++ .../sections/CharacterStatsSection.svelte | 43 +++ .../sections/CharacterTaxonomySection.svelte | 42 +++ .../sections/CharacterUncapSection.svelte | 51 +++ .../database/detail/DetailScaffold.svelte | 104 ++++++ .../database/detail/createEditForm.ts | 14 + src/lib/features/database/detail/image.ts | 56 ++++ .../features/database/summons/schema.test.ts | 39 +++ src/lib/features/database/summons/schema.ts | 62 ++++ .../sections/SummonMetadataSection.svelte | 23 ++ .../sections/SummonStatsSection.svelte | 40 +++ .../sections/SummonTaxonomySection.svelte | 21 ++ .../sections/SummonUncapSection.svelte | 27 ++ .../features/database/weapons/schema.test.ts | 43 +++ src/lib/features/database/weapons/schema.ts | 67 ++++ .../sections/WeaponMetadataSection.svelte | 23 ++ .../sections/WeaponStatsSection.svelte | 39 +++ .../sections/WeaponTaxonomySection.svelte | 29 ++ .../sections/WeaponUncapSection.svelte | 27 ++ src/lib/server/detail/load.ts | 31 ++ .../characters/[id]/page.server.test.ts | 36 +++ .../database/summons/[id]/page.server.test.ts | 35 ++ .../database/weapons/[id]/page.server.test.ts | 35 ++ 28 files changed, 1254 insertions(+), 305 deletions(-) create mode 100644 src/lib/features/database/characters/schema.test.ts create mode 100644 src/lib/features/database/characters/schema.ts create mode 100644 src/lib/features/database/characters/sections/CharacterMetadataSection.svelte create mode 100644 src/lib/features/database/characters/sections/CharacterStatsSection.svelte create mode 100644 src/lib/features/database/characters/sections/CharacterTaxonomySection.svelte create mode 100644 src/lib/features/database/characters/sections/CharacterUncapSection.svelte create mode 100644 src/lib/features/database/detail/DetailScaffold.svelte create mode 100644 src/lib/features/database/detail/createEditForm.ts create mode 100644 src/lib/features/database/detail/image.ts create mode 100644 src/lib/features/database/summons/schema.test.ts create mode 100644 src/lib/features/database/summons/schema.ts create mode 100644 src/lib/features/database/summons/sections/SummonMetadataSection.svelte create mode 100644 src/lib/features/database/summons/sections/SummonStatsSection.svelte create mode 100644 src/lib/features/database/summons/sections/SummonTaxonomySection.svelte create mode 100644 src/lib/features/database/summons/sections/SummonUncapSection.svelte create mode 100644 src/lib/features/database/weapons/schema.test.ts create mode 100644 src/lib/features/database/weapons/schema.ts create mode 100644 src/lib/features/database/weapons/sections/WeaponMetadataSection.svelte create mode 100644 src/lib/features/database/weapons/sections/WeaponStatsSection.svelte create mode 100644 src/lib/features/database/weapons/sections/WeaponTaxonomySection.svelte create mode 100644 src/lib/features/database/weapons/sections/WeaponUncapSection.svelte create mode 100644 src/lib/server/detail/load.ts create mode 100644 src/routes/database/characters/[id]/page.server.test.ts create mode 100644 src/routes/database/summons/[id]/page.server.test.ts create mode 100644 src/routes/database/weapons/[id]/page.server.test.ts diff --git a/src/lib/api/adapters/__tests__/party.adapter.test.ts b/src/lib/api/adapters/__tests__/party.adapter.test.ts index c237110c..e8f227c9 100644 --- a/src/lib/api/adapters/__tests__/party.adapter.test.ts +++ b/src/lib/api/adapters/__tests__/party.adapter.test.ts @@ -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 } + ] }) }) ) diff --git a/src/lib/api/adapters/index.ts b/src/lib/api/adapters/index.ts index 56cbc4b8..b44fc590 100644 --- a/src/lib/api/adapters/index.ts +++ b/src/lib/api/adapters/index.ts @@ -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' diff --git a/src/lib/api/adapters/party.adapter.ts b/src/lib/api/adapters/party.adapter.ts index 9e96a8ae..730e4cbc 100644 --- a/src/lib/api/adapters/party.adapter.ts +++ b/src/lib/api/adapters/party.adapter.ts @@ -153,70 +153,40 @@ export interface ListUserPartiesParams { } /** - * Parameters for updating grid items + * Grid operation for batch updates */ -export interface UpdateGridParams { - 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 - 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 - ): 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 { + return this.request(`/parties/${shortcode}/grid_update`, { + method: 'POST', body: { - grid_weapons: updates - } - }) - } - - /** - * Updates grid summons for a party - */ - async updateGridSummons( - params: UpdateGridParams - ): 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 - ): 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 { - return this.request(`/parties/${shortcode}`, { - method: 'PATCH', + return this.request(`/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 { + return this.request(`/parties/${shortcode}/job_skills`, { + method: 'PUT', + body: { + skills + } + }) + } + + /** + * Removes a job skill from a party + */ + async removeJobSkill( + shortcode: string, + skillSlot: number + ): Promise { + return this.request(`/parties/${shortcode}/job_skills`, { + method: 'DELETE', + body: { + slot: skillSlot + } + }) + } + + /** + * Gets party preview image + */ + async getPreview(shortcode: string): Promise { + return this.request(`/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 */ diff --git a/src/lib/features/database/characters/schema.test.ts b/src/lib/features/database/characters/schema.test.ts new file mode 100644 index 00000000..6382f02d --- /dev/null +++ b/src/lib/features/database/characters/schema.test.ts @@ -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) + }) +}) + diff --git a/src/lib/features/database/characters/schema.ts b/src/lib/features/database/characters/schema.ts new file mode 100644 index 00000000..2e49d23e --- /dev/null +++ b/src/lib/features/database/characters/schema.ts @@ -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 + +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 + } +} + diff --git a/src/lib/features/database/characters/sections/CharacterMetadataSection.svelte b/src/lib/features/database/characters/sections/CharacterMetadataSection.svelte new file mode 100644 index 00000000..bda2ff6c --- /dev/null +++ b/src/lib/features/database/characters/sections/CharacterMetadataSection.svelte @@ -0,0 +1,26 @@ + + + + + + {#if editMode} + + + {:else} + + + {/if} + + diff --git a/src/lib/features/database/characters/sections/CharacterStatsSection.svelte b/src/lib/features/database/characters/sections/CharacterStatsSection.svelte new file mode 100644 index 00000000..8eec075b --- /dev/null +++ b/src/lib/features/database/characters/sections/CharacterStatsSection.svelte @@ -0,0 +1,43 @@ + + + + + + {#if editMode} + + + + {:else} + + + {#if flb} + + {/if} + {/if} + + + + {#if editMode} + + + + {:else} + + + {#if flb} + + {/if} + {/if} + + diff --git a/src/lib/features/database/characters/sections/CharacterTaxonomySection.svelte b/src/lib/features/database/characters/sections/CharacterTaxonomySection.svelte new file mode 100644 index 00000000..54a5316e --- /dev/null +++ b/src/lib/features/database/characters/sections/CharacterTaxonomySection.svelte @@ -0,0 +1,42 @@ + + + + + + {#if editMode} + + + + + + + {:else} + + + {#if character.race?.[1]} + + {/if} + + + + {/if} + + diff --git a/src/lib/features/database/characters/sections/CharacterUncapSection.svelte b/src/lib/features/database/characters/sections/CharacterUncapSection.svelte new file mode 100644 index 00000000..ec9c9cfc --- /dev/null +++ b/src/lib/features/database/characters/sections/CharacterUncapSection.svelte @@ -0,0 +1,51 @@ + + + + + + {#if character.uncap} + + + + {/if} + + {#if editMode} + + + + + {/if} + + diff --git a/src/lib/features/database/detail/DetailScaffold.svelte b/src/lib/features/database/detail/DetailScaffold.svelte new file mode 100644 index 00000000..9b541cb8 --- /dev/null +++ b/src/lib/features/database/detail/DetailScaffold.svelte @@ -0,0 +1,104 @@ + + + + +
+ + + {#if saveSuccess || saveError} +
+ {#if saveSuccess} + Changes saved successfully! + {/if} + {#if saveError} + {saveError} + {/if} +
+ {/if} + + {@render children?.()} +
+ + diff --git a/src/lib/features/database/detail/createEditForm.ts b/src/lib/features/database/detail/createEditForm.ts new file mode 100644 index 00000000..11400b0c --- /dev/null +++ b/src/lib/features/database/detail/createEditForm.ts @@ -0,0 +1,14 @@ +export type ToEditData = (model: TModel) => TEdit + +export interface EditForm { + init: (model: TModel) => TEdit + reset: (model: TModel) => TEdit +} + +export function createEditForm(toEditData: ToEditData): EditForm { + return { + init: (model: TModel) => toEditData(model), + reset: (model: TModel) => toEditData(model), + } +} + diff --git a/src/lib/features/database/detail/image.ts b/src/lib/features/database/detail/image.ts new file mode 100644 index 00000000..0bbafb33 --- /dev/null +++ b/src/lib/features/database/detail/image.ts @@ -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 }) diff --git a/src/lib/features/database/summons/schema.test.ts b/src/lib/features/database/summons/schema.test.ts new file mode 100644 index 00000000..b4f98139 --- /dev/null +++ b/src/lib/features/database/summons/schema.test.ts @@ -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) + }) +}) diff --git a/src/lib/features/database/summons/schema.ts b/src/lib/features/database/summons/schema.ts new file mode 100644 index 00000000..0411a357 --- /dev/null +++ b/src/lib/features/database/summons/schema.ts @@ -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 + +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 + } + } +} + diff --git a/src/lib/features/database/summons/sections/SummonMetadataSection.svelte b/src/lib/features/database/summons/sections/SummonMetadataSection.svelte new file mode 100644 index 00000000..c4804a73 --- /dev/null +++ b/src/lib/features/database/summons/sections/SummonMetadataSection.svelte @@ -0,0 +1,23 @@ + + + + + + {#if editMode} + + + {:else} + + + {/if} + + diff --git a/src/lib/features/database/summons/sections/SummonStatsSection.svelte b/src/lib/features/database/summons/sections/SummonStatsSection.svelte new file mode 100644 index 00000000..cb9d1211 --- /dev/null +++ b/src/lib/features/database/summons/sections/SummonStatsSection.svelte @@ -0,0 +1,40 @@ + + + + + + {#if editMode} + + + + {:else} + + + {#if flb} + + {/if} + {/if} + + + + {#if editMode} + + + + {:else} + + + {#if flb} + + {/if} + {/if} + + diff --git a/src/lib/features/database/summons/sections/SummonTaxonomySection.svelte b/src/lib/features/database/summons/sections/SummonTaxonomySection.svelte new file mode 100644 index 00000000..39316427 --- /dev/null +++ b/src/lib/features/database/summons/sections/SummonTaxonomySection.svelte @@ -0,0 +1,21 @@ + + + + + + {#if editMode} + + {:else} + + {/if} + + diff --git a/src/lib/features/database/summons/sections/SummonUncapSection.svelte b/src/lib/features/database/summons/sections/SummonUncapSection.svelte new file mode 100644 index 00000000..0722529e --- /dev/null +++ b/src/lib/features/database/summons/sections/SummonUncapSection.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + {#if editMode} + + + + {/if} + + diff --git a/src/lib/features/database/weapons/schema.test.ts b/src/lib/features/database/weapons/schema.test.ts new file mode 100644 index 00000000..6ff9c25d --- /dev/null +++ b/src/lib/features/database/weapons/schema.test.ts @@ -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) + }) +}) + diff --git a/src/lib/features/database/weapons/schema.ts b/src/lib/features/database/weapons/schema.ts new file mode 100644 index 00000000..0ecffe4d --- /dev/null +++ b/src/lib/features/database/weapons/schema.ts @@ -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 + +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 + } + } +} + diff --git a/src/lib/features/database/weapons/sections/WeaponMetadataSection.svelte b/src/lib/features/database/weapons/sections/WeaponMetadataSection.svelte new file mode 100644 index 00000000..87c43175 --- /dev/null +++ b/src/lib/features/database/weapons/sections/WeaponMetadataSection.svelte @@ -0,0 +1,23 @@ + + + + + + {#if editMode} + + + {:else} + + + {/if} + + diff --git a/src/lib/features/database/weapons/sections/WeaponStatsSection.svelte b/src/lib/features/database/weapons/sections/WeaponStatsSection.svelte new file mode 100644 index 00000000..509e7cf0 --- /dev/null +++ b/src/lib/features/database/weapons/sections/WeaponStatsSection.svelte @@ -0,0 +1,39 @@ + + + + + + {#if editMode} + + + + {:else} + + + {#if flb} + + {/if} + {/if} + + + + {#if editMode} + + + + {:else} + + + {#if flb} + + {/if} + {/if} + diff --git a/src/lib/features/database/weapons/sections/WeaponTaxonomySection.svelte b/src/lib/features/database/weapons/sections/WeaponTaxonomySection.svelte new file mode 100644 index 00000000..48f5eac2 --- /dev/null +++ b/src/lib/features/database/weapons/sections/WeaponTaxonomySection.svelte @@ -0,0 +1,29 @@ + + + + + + {#if editMode} + + + + {:else} + + + {#if Array.isArray(weapon.proficiency) && weapon.proficiency[1] !== undefined} + + {/if} + {/if} + + diff --git a/src/lib/features/database/weapons/sections/WeaponUncapSection.svelte b/src/lib/features/database/weapons/sections/WeaponUncapSection.svelte new file mode 100644 index 00000000..c70a1e70 --- /dev/null +++ b/src/lib/features/database/weapons/sections/WeaponUncapSection.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + {#if editMode} + + + + {/if} + + diff --git a/src/lib/server/detail/load.ts b/src/lib/server/detail/load.ts new file mode 100644 index 00000000..cbe012c8 --- /dev/null +++ b/src/lib/server/detail/load.ts @@ -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(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 }) => { + const { role } = await parent() + const item = await getResourceDetail(fetch, type, params.id, normalize) + return { [singular(type)]: item, role } + } + diff --git a/src/routes/database/characters/[id]/page.server.test.ts b/src/routes/database/characters/[id]/page.server.test.ts new file mode 100644 index 00000000..5b2898b8 --- /dev/null +++ b/src/routes/database/characters/[id]/page.server.test.ts @@ -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) + }) +}) + diff --git a/src/routes/database/summons/[id]/page.server.test.ts b/src/routes/database/summons/[id]/page.server.test.ts new file mode 100644 index 00000000..47fb499a --- /dev/null +++ b/src/routes/database/summons/[id]/page.server.test.ts @@ -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) + }) +}) diff --git a/src/routes/database/weapons/[id]/page.server.test.ts b/src/routes/database/weapons/[id]/page.server.test.ts new file mode 100644 index 00000000..2b1dd5d6 --- /dev/null +++ b/src/routes/database/weapons/[id]/page.server.test.ts @@ -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) + }) +})