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:
parent
114427241f
commit
4f8beab3ea
28 changed files with 1254 additions and 305 deletions
|
|
@ -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 }
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
46
src/lib/features/database/characters/schema.test.ts
Normal file
46
src/lib/features/database/characters/schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
80
src/lib/features/database/characters/schema.ts
Normal file
80
src/lib/features/database/characters/schema.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
104
src/lib/features/database/detail/DetailScaffold.svelte
Normal file
104
src/lib/features/database/detail/DetailScaffold.svelte
Normal 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>
|
||||
14
src/lib/features/database/detail/createEditForm.ts
Normal file
14
src/lib/features/database/detail/createEditForm.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
|
||||
56
src/lib/features/database/detail/image.ts
Normal file
56
src/lib/features/database/detail/image.ts
Normal 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 })
|
||||
39
src/lib/features/database/summons/schema.test.ts
Normal file
39
src/lib/features/database/summons/schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
src/lib/features/database/summons/schema.ts
Normal file
62
src/lib/features/database/summons/schema.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
43
src/lib/features/database/weapons/schema.test.ts
Normal file
43
src/lib/features/database/weapons/schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
67
src/lib/features/database/weapons/schema.ts
Normal file
67
src/lib/features/database/weapons/schema.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
31
src/lib/server/detail/load.ts
Normal file
31
src/lib/server/detail/load.ts
Normal 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 }
|
||||
}
|
||||
|
||||
36
src/routes/database/characters/[id]/page.server.test.ts
Normal file
36
src/routes/database/characters/[id]/page.server.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
35
src/routes/database/summons/[id]/page.server.test.ts
Normal file
35
src/routes/database/summons/[id]/page.server.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
35
src/routes/database/weapons/[id]/page.server.test.ts
Normal file
35
src/routes/database/weapons/[id]/page.server.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue