Merge pull request #31 from Yiling-J/add-example-data-seeding-14730136673616286047

Add example data seeding on startup
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-16 12:23:13 +08:00
commit 46eb0b2d61
5 files changed
+652 -1

No files matched your search

+2 -1
View File
@@ -7,7 +7,8 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "bun test",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"generate-example": "bun run src/scripts/generate-example-data.ts"
},
"dependencies": {
"@google/genai": "^1.40.0",
+19
View File
@@ -14,6 +14,9 @@ import outfit from './api/outfit'
import pose from './api/pose'
import restore from './api/restore'
import setting from './api/setting'
import { prisma } from './db'
import { exampleDataService } from './service/example-data'
import { settingService } from './service/setting'
import index from './ui/index.html'
// Ensure data directories exist
@@ -62,6 +65,22 @@ if (process.env.NODE_ENV === 'production') {
// Export type for RPC
export type AppType = typeof route
// Check and seed example data
const initialized = await settingService.get('system_initialized')
if (!initialized) {
try {
const hasData = (await prisma.character.count()) > 0
if (!hasData) {
console.log('🌱 Seeding example data...')
await exampleDataService.import()
console.log('✅ Example data seeded')
}
await settingService.set('system_initialized', 'true')
} catch (error) {
console.error('Failed to seed example data:', error)
}
}
// Serve the frontend
const server = Bun.serve({
port: 3000,
+17
View File
@@ -0,0 +1,17 @@
import { exampleDataService } from '../service/example-data'
import { prisma } from '../db'
async function main() {
console.log('Starting example data generation...')
try {
await exampleDataService.export()
console.log('Example data generation completed successfully.')
} catch (error) {
console.error('Error generating example data:', error)
process.exit(1)
} finally {
await prisma.$disconnect()
}
}
main()
+300
View File
@@ -0,0 +1,300 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test'
// 1. Mock node:fs for mkdirSync and existsSync
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const mockExistsSync = mock((path: string) => false)
const mockMkdirSync = mock(() => undefined)
mock.module('node:fs', () => ({
existsSync: mockExistsSync,
mkdirSync: mockMkdirSync,
}))
// 2. Mock Bun globals
const mockBunWrite = mock(() => Promise.resolve(0))
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const mockBunFile = mock((path: string) => ({
exists: mock(() => Promise.resolve(false)),
// @ts-ignore
json: mock(() => Promise.resolve([])),
arrayBuffer: mock(() => Promise.resolve(new ArrayBuffer(0))),
text: mock(() => Promise.resolve('')),
}))
// @ts-ignore
global.Bun.write = mockBunWrite
// @ts-ignore
global.Bun.file = mockBunFile
// Mock prisma client
const mockPrisma = {
character: {
// @ts-ignore
findMany: mock(() => Promise.resolve([])),
// @ts-ignore
create: mock(() => Promise.resolve({})),
// @ts-ignore
findUnique: mock(() => Promise.resolve({})),
},
equipment: {
// @ts-ignore
findMany: mock(() => Promise.resolve([])),
// @ts-ignore
create: mock(() => Promise.resolve({})),
},
pose: {
// @ts-ignore
findMany: mock(() => Promise.resolve([])),
// @ts-ignore
create: mock(() => Promise.resolve({})),
},
expression: {
// @ts-ignore
findMany: mock(() => Promise.resolve([])),
// @ts-ignore
create: mock(() => Promise.resolve({})),
},
generation: {
// @ts-ignore
findMany: mock(() => Promise.resolve([])),
// @ts-ignore
create: mock(() => Promise.resolve({})),
},
// @ts-ignore
asset: { create: mock(() => Promise.resolve({ id: 'mock-asset-id' })) },
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
$transaction: mock((cb: any) => cb(mockPrisma)),
}
mock.module('../db', () => ({
prisma: mockPrisma,
}))
// Mock asset service
const mockAssetService = {
createAssetFromBuffer: mock(() => Promise.resolve({ id: 'new-asset-id', path: 'new-path.webp' })),
}
mock.module('./asset', () => ({
assetService: mockAssetService,
}))
// Import the service to test
import { exampleDataService } from './example-data'
describe('ExampleDataService', () => {
beforeEach(() => {
// Reset mocks before each test
mockExistsSync.mockClear()
mockMkdirSync.mockClear()
mockBunWrite.mockClear()
mockBunFile.mockClear()
mockPrisma.character.findMany.mockClear()
mockPrisma.equipment.findMany.mockClear()
mockPrisma.pose.findMany.mockClear()
mockPrisma.expression.findMany.mockClear()
mockPrisma.generation.findMany.mockClear()
mockPrisma.character.create.mockClear()
mockPrisma.character.findUnique.mockClear()
mockPrisma.equipment.create.mockClear()
mockPrisma.pose.create.mockClear()
mockPrisma.expression.create.mockClear()
mockPrisma.generation.create.mockClear()
mockAssetService.createAssetFromBuffer.mockClear()
})
describe('export', () => {
it('should export all data types to JSON files', async () => {
// Setup mock return values for findMany
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma.character.findMany.mockResolvedValue([
{
id: 'c1',
name: 'Char 1',
description: 'Desc 1',
image: { name: 'img1.png', type: 'image/png', path: 'img1.webp' },
},
] as any)
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma.equipment.findMany.mockResolvedValue([
{
id: 'e1',
name: 'Eq 1',
description: 'Desc E1',
category: 'Cat1',
subCategory: null,
image: { name: 'imgE1.png', type: 'image/png', path: 'imgE1.webp' },
},
] as any)
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma.pose.findMany.mockResolvedValue([
{
id: 'p1',
name: 'Pose 1',
image: { name: 'imgP1.png', type: 'image/png', path: 'imgP1.webp' },
},
] as any)
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma.expression.findMany.mockResolvedValue([
{
id: 'ex1',
name: 'Expr 1',
image: { name: 'imgEx1.png', type: 'image/png', path: 'imgEx1.webp' },
},
] as any)
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma.generation.findMany.mockResolvedValue([
{
id: 'g1',
characterId: 'c1',
userPrompt: 'prompt',
pose: 'pose',
expression: 'expr',
image: { name: 'imgG1.png', type: 'image/png', path: 'imgG1.webp' },
equipments: [{ equipmentId: 'e1' }],
},
] as any)
// Mock Bun.file to exist so copyAsset works
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockBunFile.mockImplementation((path: string) => ({
exists: mock(() => Promise.resolve(path.startsWith('data/files'))),
json: mock(() => Promise.resolve([])),
arrayBuffer: mock(() => Promise.resolve(new ArrayBuffer(0))),
text: mock(() => Promise.resolve('')),
}))
await exampleDataService.export()
// Verify directories created
expect(mockMkdirSync).toHaveBeenCalledTimes(2) // example-data and example-data/assets
// Verify data fetching
expect(mockPrisma.character.findMany).toHaveBeenCalled()
expect(mockPrisma.equipment.findMany).toHaveBeenCalled()
expect(mockPrisma.pose.findMany).toHaveBeenCalled()
expect(mockPrisma.expression.findMany).toHaveBeenCalled()
expect(mockPrisma.generation.findMany).toHaveBeenCalled()
// Verify JSON writing
// 5 JSON files + 5 Asset copies = 10 calls to Bun.write
expect(mockBunWrite).toHaveBeenCalledTimes(10)
const calls = mockBunWrite.mock.calls
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const writtenPaths = calls.map((c: any) => c[0])
expect(writtenPaths).toContain('example-data/characters.json')
expect(writtenPaths).toContain('example-data/equipments.json')
expect(writtenPaths).toContain('example-data/poses.json')
expect(writtenPaths).toContain('example-data/expressions.json')
expect(writtenPaths).toContain('example-data/looks.json')
})
})
describe('import', () => {
it('should import data from JSON files', async () => {
// Mock directory existence
mockExistsSync.mockReturnValue(true)
// Mock Bun.file for reading JSONs
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockBunFile.mockImplementation((path: string) => {
const mockExists = mock(() => Promise.resolve(true))
const mockJson = mock(() => {
if (path.includes('characters.json'))
return Promise.resolve([
{
id: 'c1',
name: 'Char 1',
description: 'Desc 1',
asset: { name: 'img1', type: 'image/png', path: 'img1.webp' },
},
])
if (path.includes('equipments.json'))
return Promise.resolve([
{
id: 'e1',
name: 'Eq 1',
description: 'Desc E1',
category: 'Cat1',
subCategory: null,
asset: { name: 'imgE1', type: 'image/png', path: 'imgE1.webp' },
},
])
if (path.includes('poses.json'))
return Promise.resolve([
{
id: 'p1',
name: 'Pose 1',
asset: { name: 'imgP1', type: 'image/png', path: 'imgP1.webp' },
},
])
if (path.includes('expressions.json'))
return Promise.resolve([
{
id: 'ex1',
name: 'Expr 1',
asset: { name: 'imgEx1', type: 'image/png', path: 'imgEx1.webp' },
},
])
if (path.includes('looks.json'))
return Promise.resolve([
{
id: 'g1',
characterId: 'c1',
userPrompt: 'prompt',
pose: 'pose',
expression: 'expr',
asset: { name: 'imgG1', type: 'image/png', path: 'imgG1.webp' },
equipmentIds: ['e1'],
},
])
return Promise.resolve([])
})
const mockArrayBuffer = mock(() => Promise.resolve(new ArrayBuffer(10)))
return {
exists: mockExists,
// @ts-ignore
json: mockJson,
arrayBuffer: mockArrayBuffer,
text: mock(() => Promise.resolve('')),
}
})
// Mock Prisma finds needed for validation
// @ts-ignore
mockPrisma.character.findUnique.mockResolvedValue({ id: 'c1' })
await exampleDataService.import()
// Verify asset creation (5 assets)
expect(mockAssetService.createAssetFromBuffer).toHaveBeenCalledTimes(5)
// Verify DB creation
expect(mockPrisma.character.create).toHaveBeenCalledTimes(1)
expect(mockPrisma.equipment.create).toHaveBeenCalledTimes(1)
expect(mockPrisma.pose.create).toHaveBeenCalledTimes(1)
expect(mockPrisma.expression.create).toHaveBeenCalledTimes(1)
expect(mockPrisma.generation.create).toHaveBeenCalledTimes(1)
})
it('should skip import if example data folder does not exist', async () => {
mockExistsSync.mockReturnValue(false)
await exampleDataService.import()
expect(mockBunFile).not.toHaveBeenCalled()
})
})
})
+314
View File
@@ -0,0 +1,314 @@
import { existsSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { prisma } from '../db'
import { assetService } from './asset'
interface AssetData {
name: string
type: string
path: string // filename in example-data/assets
}
interface CharacterData {
id: string
name: string
description: string | null
asset: AssetData
}
interface EquipmentData {
id: string
name: string
description: string
category: string
subCategory: string | null
asset: AssetData
}
interface PoseData {
id: string
name: string
asset: AssetData
}
interface ExpressionData {
id: string
name: string
asset: AssetData
}
interface GenerationData {
id: string
characterId: string
userPrompt: string | null
pose: string | null
expression: string | null
asset: AssetData
equipmentIds: string[]
}
export class ExampleDataService {
private baseDir = 'example-data'
private assetsDir = join(this.baseDir, 'assets')
constructor() {
// ensure base directories exist on instantiation (or method call)
}
private ensureDirs() {
if (!existsSync(this.baseDir)) {
mkdirSync(this.baseDir)
}
if (!existsSync(this.assetsDir)) {
mkdirSync(this.assetsDir)
}
}
async export() {
this.ensureDirs()
// 1. Characters
const characters = await prisma.character.findMany({ include: { image: true } })
const characterData: CharacterData[] = []
for (const c of characters) {
await this.copyAsset(c.image.path)
characterData.push({
id: c.id,
name: c.name,
description: c.description,
asset: {
name: c.image.name,
type: c.image.type,
path: c.image.path,
},
})
}
await Bun.write(join(this.baseDir, 'characters.json'), JSON.stringify(characterData, null, 2))
// 2. Equipments
const equipments = await prisma.equipment.findMany({ include: { image: true } })
const equipmentData: EquipmentData[] = []
for (const e of equipments) {
await this.copyAsset(e.image.path)
equipmentData.push({
id: e.id,
name: e.name,
description: e.description,
category: e.category,
subCategory: e.subCategory,
asset: {
name: e.image.name,
type: e.image.type,
path: e.image.path,
},
})
}
await Bun.write(join(this.baseDir, 'equipments.json'), JSON.stringify(equipmentData, null, 2))
// 3. Poses
const poses = await prisma.pose.findMany({ include: { image: true } })
const poseData: PoseData[] = []
for (const p of poses) {
await this.copyAsset(p.image.path)
poseData.push({
id: p.id,
name: p.name,
asset: {
name: p.image.name,
type: p.image.type,
path: p.image.path,
},
})
}
await Bun.write(join(this.baseDir, 'poses.json'), JSON.stringify(poseData, null, 2))
// 4. Expressions
const expressions = await prisma.expression.findMany({ include: { image: true } })
const expressionData: ExpressionData[] = []
for (const e of expressions) {
await this.copyAsset(e.image.path)
expressionData.push({
id: e.id,
name: e.name,
asset: {
name: e.image.name,
type: e.image.type,
path: e.image.path,
},
})
}
await Bun.write(join(this.baseDir, 'expressions.json'), JSON.stringify(expressionData, null, 2))
// 5. Generations (Looks)
const generations = await prisma.generation.findMany({
include: {
image: true,
equipments: true,
},
})
const generationData: GenerationData[] = []
for (const g of generations) {
await this.copyAsset(g.image.path)
generationData.push({
id: g.id,
characterId: g.characterId,
userPrompt: g.userPrompt,
pose: g.pose,
expression: g.expression,
asset: {
name: g.image.name,
type: g.image.type,
path: g.image.path,
},
equipmentIds: g.equipments.map((e) => e.equipmentId),
})
}
await Bun.write(join(this.baseDir, 'looks.json'), JSON.stringify(generationData, null, 2))
console.log(`Exported ${characters.length} characters`)
console.log(`Exported ${equipments.length} equipments`)
console.log(`Exported ${poses.length} poses`)
console.log(`Exported ${expressions.length} expressions`)
console.log(`Exported ${generations.length} looks`)
}
async import() {
if (!existsSync(this.baseDir)) {
console.log('No example data found, skipping import.')
return
}
// 1. Characters
const charFile = Bun.file(join(this.baseDir, 'characters.json'))
if (await charFile.exists()) {
const data: CharacterData[] = await charFile.json()
for (const item of data) {
const asset = await this.createAssetFromExample(item.asset)
await prisma.character.create({
data: {
id: item.id,
name: item.name,
description: item.description,
imageId: asset.id,
},
})
}
console.log(`Imported ${data.length} characters`)
}
// 2. Equipments
const equipFile = Bun.file(join(this.baseDir, 'equipments.json'))
if (await equipFile.exists()) {
const data: EquipmentData[] = await equipFile.json()
for (const item of data) {
const asset = await this.createAssetFromExample(item.asset)
await prisma.equipment.create({
data: {
id: item.id,
name: item.name,
description: item.description,
category: item.category,
subCategory: item.subCategory,
imageId: asset.id,
},
})
}
console.log(`Imported ${data.length} equipments`)
}
// 3. Poses
const poseFile = Bun.file(join(this.baseDir, 'poses.json'))
if (await poseFile.exists()) {
const data: PoseData[] = await poseFile.json()
for (const item of data) {
const asset = await this.createAssetFromExample(item.asset)
await prisma.pose.create({
data: {
id: item.id,
name: item.name,
imageId: asset.id,
},
})
}
console.log(`Imported ${data.length} poses`)
}
// 4. Expressions
const exprFile = Bun.file(join(this.baseDir, 'expressions.json'))
if (await exprFile.exists()) {
const data: ExpressionData[] = await exprFile.json()
for (const item of data) {
const asset = await this.createAssetFromExample(item.asset)
await prisma.expression.create({
data: {
id: item.id,
name: item.name,
imageId: asset.id,
},
})
}
console.log(`Imported ${data.length} expressions`)
}
// 5. Generations (Looks)
const lookFile = Bun.file(join(this.baseDir, 'looks.json'))
if (await lookFile.exists()) {
const data: GenerationData[] = await lookFile.json()
for (const item of data) {
// Ensure character exists
const characterExists = await prisma.character.findUnique({
where: { id: item.characterId },
})
if (!characterExists) {
console.warn(`Skipping look ${item.id}: Character ${item.characterId} not found`)
continue
}
const asset = await this.createAssetFromExample(item.asset)
await prisma.generation.create({
data: {
id: item.id,
characterId: item.characterId,
userPrompt: item.userPrompt,
pose: item.pose,
expression: item.expression,
imageId: asset.id,
equipments: {
create: item.equipmentIds.map((id) => ({
equipment: { connect: { id } },
})),
},
},
})
}
console.log(`Imported ${data.length} looks`)
}
}
private async copyAsset(filename: string) {
const src = join('data/files', filename)
const dest = join(this.assetsDir, filename)
const file = Bun.file(src)
if (await file.exists()) {
await Bun.write(dest, file)
} else {
console.warn(`Asset file not found: ${src}`)
}
}
private async createAssetFromExample(assetData: AssetData) {
const filePath = join(this.assetsDir, assetData.path)
const file = Bun.file(filePath)
if (!(await file.exists())) {
throw new Error(`Example asset file not found: ${filePath}`)
}
const buffer = await file.arrayBuffer()
// We use createAssetFromBuffer which handles saving to data/files and DB creation
return assetService.createAssetFromBuffer(buffer, {
name: assetData.name,
type: assetData.type,
})
}
}
export const exampleDataService = new ExampleDataService()