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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { PartyAdapter } from '../party.adapter'
|
import { PartyAdapter } from '../party.adapter'
|
||||||
import type { Party, GridWeapon, GridSummon, GridCharacter } from '../party.adapter'
|
import type { Party } from '../party.adapter'
|
||||||
|
|
||||||
describe('PartyAdapter', () => {
|
describe('PartyAdapter', () => {
|
||||||
let adapter: PartyAdapter
|
let adapter: PartyAdapter
|
||||||
|
|
@ -202,188 +202,78 @@ describe('PartyAdapter', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('grid management', () => {
|
describe('grid management', () => {
|
||||||
it('should update grid weapons', async () => {
|
it('should perform batch grid updates', async () => {
|
||||||
const mockGridWeapons: GridWeapon[] = [
|
const mockResponse = {
|
||||||
{
|
party: mockParty,
|
||||||
id: 'gw-1',
|
operations_applied: 2,
|
||||||
position: 1,
|
changes: [
|
||||||
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: [
|
|
||||||
{
|
{
|
||||||
position: 1,
|
entity: 'weapon',
|
||||||
weaponId: 'weapon-1',
|
id: 'gw-1',
|
||||||
mainhand: true,
|
action: 'moved',
|
||||||
uncapLevel: 5
|
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({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => conflictResponse
|
json: async () => mockResponse
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await adapter.updateGridWeapons({
|
const operations = [
|
||||||
shortcode: 'ABC123',
|
{
|
||||||
updates: [
|
type: 'move' as const,
|
||||||
{
|
entity: 'weapon' as const,
|
||||||
position: 1,
|
id: 'gw-1',
|
||||||
weaponId: 'weapon-2'
|
position: 2
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
})
|
type: 'swap' as const,
|
||||||
|
entity: 'character' as const,
|
||||||
|
sourceId: 'gc-1',
|
||||||
|
targetId: 'gc-2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
expect(result.conflicts).toBeDefined()
|
const result = await adapter.gridUpdate(
|
||||||
expect(result.conflicts?.resolved).toBe(false)
|
'ABC123',
|
||||||
expect(result.conflicts?.conflicts).toHaveLength(1)
|
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: {
|
job: {
|
||||||
id: 'job-2',
|
id: 'job-2',
|
||||||
name: { en: 'Mage' },
|
name: { en: 'Mage' },
|
||||||
skills: [
|
skills: []
|
||||||
{
|
|
||||||
id: 'skill-2',
|
|
||||||
name: { en: 'Fireball' },
|
|
||||||
slot: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
accessory: {
|
|
||||||
id: 'acc-1',
|
|
||||||
name: { en: 'Magic Ring' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,24 +293,55 @@ describe('PartyAdapter', () => {
|
||||||
json: async () => updatedParty
|
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',
|
'ABC123',
|
||||||
'job-2',
|
[
|
||||||
[{ id: 'skill-2', slot: 1 }],
|
{ id: 'skill-1', slot: 1 },
|
||||||
'acc-1'
|
{ id: 'skill-2', slot: 2 }
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toEqual(updatedParty)
|
expect(result).toEqual(updatedParty)
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
'https://api.example.com/parties/ABC123',
|
'https://api.example.com/parties/ABC123/job_skills',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PATCH',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
party: {
|
skills: [
|
||||||
job_id: 'job-2',
|
{ id: 'skill-1', slot: 1 },
|
||||||
job_skills_attributes: [{ id: 'skill-2', slot: 1 }],
|
{ id: 'skill-2', slot: 2 }
|
||||||
job_accessory_id: 'acc-1'
|
]
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,9 @@ export type {
|
||||||
CreatePartyParams,
|
CreatePartyParams,
|
||||||
UpdatePartyParams,
|
UpdatePartyParams,
|
||||||
ListUserPartiesParams,
|
ListUserPartiesParams,
|
||||||
UpdateGridParams,
|
GridOperation,
|
||||||
GridWeaponUpdate,
|
GridUpdateOptions,
|
||||||
GridSummonUpdate,
|
GridUpdateResponse
|
||||||
GridCharacterUpdate,
|
|
||||||
ConflictResolution
|
|
||||||
} from './party.adapter'
|
} from './party.adapter'
|
||||||
|
|
||||||
// export { GridAdapter } from './grid.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> {
|
export interface GridOperation {
|
||||||
shortcode: string
|
type: 'move' | 'swap' | 'remove'
|
||||||
updates: T[]
|
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 {
|
export interface GridUpdateOptions {
|
||||||
id?: string
|
maintainCharacterSequence?: boolean
|
||||||
position: number
|
validateBeforeExecute?: boolean
|
||||||
weaponId: string
|
}
|
||||||
mainhand?: boolean
|
|
||||||
uncapLevel?: number
|
/**
|
||||||
transcendenceStage?: number
|
* Response from grid update operation
|
||||||
weaponKeys?: Array<{
|
*/
|
||||||
|
export interface GridUpdateResponse {
|
||||||
|
party: Party
|
||||||
|
operationsApplied: number
|
||||||
|
changes: Array<{
|
||||||
|
entity: string
|
||||||
id: 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(
|
async gridUpdate(
|
||||||
params: UpdateGridParams<GridWeaponUpdate>
|
shortcode: string,
|
||||||
): Promise<{ gridWeapons: GridWeapon[]; conflicts?: ConflictResolution }> {
|
operations: GridOperation[],
|
||||||
const { shortcode, updates } = params
|
options?: GridUpdateOptions
|
||||||
return this.request(`/parties/${shortcode}/grid_weapons`, {
|
): Promise<GridUpdateResponse> {
|
||||||
method: 'PATCH',
|
return this.request(`/parties/${shortcode}/grid_update`, {
|
||||||
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
grid_weapons: updates
|
operations,
|
||||||
}
|
options
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -344,22 +287,80 @@ export class PartyAdapter extends BaseAdapter {
|
||||||
*/
|
*/
|
||||||
async updateJob(
|
async updateJob(
|
||||||
shortcode: string,
|
shortcode: string,
|
||||||
jobId: string,
|
jobId: string
|
||||||
skills?: Array<{ id: string; slot: number }>,
|
|
||||||
accessoryId?: string
|
|
||||||
): Promise<Party> {
|
): Promise<Party> {
|
||||||
return this.request<Party>(`/parties/${shortcode}`, {
|
return this.request<Party>(`/parties/${shortcode}/jobs`, {
|
||||||
method: 'PATCH',
|
method: 'PUT',
|
||||||
body: {
|
body: {
|
||||||
party: {
|
job_id: jobId
|
||||||
job_id: jobId,
|
|
||||||
...(skills && { job_skills_attributes: skills }),
|
|
||||||
...(accessoryId && { job_accessory_id: accessoryId })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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