Implement character and scenario management features in the API

- Add create, update, and delete functionality for characters and scenarios in CharacterService and ScenarioService.
- Introduce CreateCharacterRequest and CreateScenarioRequest models for handling character and scenario creation requests.
- Update CharactersResource and ScenariosResource to expose new endpoints for character and scenario management.
- Enhance OpenAPI specification to document new API endpoints and request/response schemas.
- Implement frontend components for managing characters and scenarios, including listing, creating, editing, and deleting.
- Add unit tests for CharacterService to ensure correct behavior of character creation and updates.
This commit is contained in:
Konrad Neitzel 2026-02-22 05:02:20 +01:00
parent 3ce1215487
commit 4c1584ec27
14 changed files with 1455 additions and 17 deletions

View File

@ -3,6 +3,7 @@ package de.neitzel.roleplay.business;
import de.neitzel.roleplay.data.CharacterEntity;
import de.neitzel.roleplay.data.CharacterRepository;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ -47,6 +48,73 @@ public class CharacterService {
return entity != null ? Optional.of(toCharacterDefinition(entity)) : Optional.empty();
}
/**
* Creates a new character from the request. Uses request id if present, otherwise generates a UUID.
*
* @param request the create request (name and role required)
* @return the created character definition
*/
public CharacterDefinition create(final CreateCharacterRequest request) {
CharacterEntity entity = fromRequest(request, request.getId() != null ? request.getId() : UUID.randomUUID());
characterRepository.persist(entity);
return toCharacterDefinition(entity);
}
/**
* Updates an existing character by id. Full replace of all fields.
*
* @param id the character UUID
* @param request the update request
* @return the updated character definition
* @throws java.util.NoSuchElementException if the character does not exist
*/
public CharacterDefinition update(final UUID id, final CreateCharacterRequest request) {
CharacterEntity entity = characterRepository.findByIdOptional(id);
if (entity == null) {
throw new java.util.NoSuchElementException("No character found with id: " + id);
}
applyRequest(entity, request);
characterRepository.persist(entity);
return toCharacterDefinition(entity);
}
/**
* Deletes a character by id.
*
* @param id the character UUID
* @return true if deleted, false if no character existed
*/
public boolean delete(final UUID id) {
CharacterEntity entity = characterRepository.findByIdOptional(id);
if (entity == null) {
return false;
}
characterRepository.delete(entity);
return true;
}
/**
* Builds a new entity from the request and the given id.
*/
private static CharacterEntity fromRequest(final CreateCharacterRequest request, final UUID id) {
CharacterEntity entity = new CharacterEntity();
entity.setId(id);
applyRequest(entity, request);
return entity;
}
/**
* Applies request fields to an existing entity (id is not changed).
*/
private static void applyRequest(final CharacterEntity entity, final CreateCharacterRequest request) {
entity.setName(request.getName());
entity.setRole(request.getRole());
entity.setBackstory(request.getBackstory());
entity.setSpeakingStyle(request.getSpeakingStyle());
entity.setPersonalityTraits(request.getPersonalityTraits() != null ? request.getPersonalityTraits() : List.of());
entity.setGoals(request.getGoals() != null ? request.getGoals() : List.of());
}
/**
* Maps a character entity to the API CharacterDefinition. Uses entity id as string for API id.
*/

View File

@ -1,14 +1,18 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.data.CharacterRepository;
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
import de.neitzel.roleplay.data.ScenarioEntity;
import de.neitzel.roleplay.data.ScenarioRepository;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CreateScenarioRequest;
import de.neitzel.roleplay.fascade.model.ScenarioCharacterSlot;
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;
@ -22,10 +26,15 @@ import java.util.UUID;
public class ScenarioService {
private final ScenarioRepository scenarioRepository;
private final CharacterRepository characterRepository;
private final EntityManager entityManager;
@Inject
public ScenarioService(final ScenarioRepository scenarioRepository) {
public ScenarioService(final ScenarioRepository scenarioRepository, final CharacterRepository characterRepository,
final EntityManager entityManager) {
this.scenarioRepository = scenarioRepository;
this.characterRepository = characterRepository;
this.entityManager = entityManager;
}
/**
@ -50,6 +59,97 @@ public class ScenarioService {
return entity != null ? Optional.of(toScenarioSetup(entity)) : Optional.empty();
}
/**
* Creates a new scenario from the request. Validates that all referenced characters exist.
*
* @param request the create request (name required; optional setting, initialConflict, characterSlots)
* @return the created scenario summary
* @throws IllegalArgumentException if any referenced character id is not found
*/
public ScenarioSummary create(final CreateScenarioRequest request) {
UUID scenarioId = UUID.randomUUID();
ScenarioEntity scenario = new ScenarioEntity();
scenario.setId(scenarioId);
scenario.setName(request.getName());
scenario.setSetting(request.getSetting());
scenario.setInitialConflict(request.getInitialConflict());
List<ScenarioCharacterSlot> slots = request.getCharacterSlots() != null ? request.getCharacterSlots() : List.of();
for (ScenarioCharacterSlot slot : slots) {
var character = characterRepository.findByIdOptional(slot.getCharacterId());
if (character == null) {
throw new IllegalArgumentException("Character not found: " + slot.getCharacterId());
}
ScenarioCharacterEntity link = new ScenarioCharacterEntity();
link.setId(UUID.randomUUID());
link.setScenario(scenario);
link.setCharacter(character);
link.setUserCharacter(Boolean.TRUE.equals(slot.getIsUserCharacter()));
link.setPosition(slot.getPosition() != null ? slot.getPosition() : 0);
scenario.getScenarioCharacters().add(link);
}
scenarioRepository.persist(scenario);
for (ScenarioCharacterEntity link : scenario.getScenarioCharacters()) {
entityManager.persist(link);
}
entityManager.flush();
return toScenarioSummary(scenario);
}
/**
* Updates an existing scenario by id. Full replace; replaces all character slots.
*
* @param id the scenario UUID
* @param request the update request
* @return the updated scenario summary
* @throws java.util.NoSuchElementException if the scenario does not exist
* @throws IllegalArgumentException if any referenced character id is not found
*/
public ScenarioSummary update(final UUID id, final CreateScenarioRequest request) {
ScenarioEntity scenario = scenarioRepository.findByIdWithCharacters(id);
if (scenario == null) {
throw new java.util.NoSuchElementException("No scenario found with id: " + id);
}
scenario.setName(request.getName());
scenario.setSetting(request.getSetting());
scenario.setInitialConflict(request.getInitialConflict());
scenario.getScenarioCharacters().clear();
List<ScenarioCharacterSlot> slots = request.getCharacterSlots() != null ? request.getCharacterSlots() : List.of();
for (ScenarioCharacterSlot slot : slots) {
var character = characterRepository.findByIdOptional(slot.getCharacterId());
if (character == null) {
throw new IllegalArgumentException("Character not found: " + slot.getCharacterId());
}
ScenarioCharacterEntity link = new ScenarioCharacterEntity();
link.setId(UUID.randomUUID());
link.setScenario(scenario);
link.setCharacter(character);
link.setUserCharacter(Boolean.TRUE.equals(slot.getIsUserCharacter()));
link.setPosition(slot.getPosition() != null ? slot.getPosition() : 0);
scenario.getScenarioCharacters().add(link);
}
scenarioRepository.persist(scenario);
for (ScenarioCharacterEntity link : scenario.getScenarioCharacters()) {
entityManager.persist(link);
}
entityManager.flush();
return toScenarioSummary(scenario);
}
/**
* Deletes a scenario by id. Cascades to scenario-character links.
*
* @param id the scenario UUID
* @return true if deleted, false if no scenario existed
*/
public boolean delete(final UUID id) {
ScenarioEntity scenario = scenarioRepository.findByIdWithCharacters(id);
if (scenario == null) {
return false;
}
scenarioRepository.delete(scenario);
return true;
}
/**
* Maps a scenario entity to the list-summary DTO.
*/

View File

@ -35,7 +35,7 @@ public class ScenarioEntity extends PanacheEntityBase {
private String initialConflict;
@OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("isUserCharacter DESC, position ASC")
@OrderBy("userCharacter DESC, position ASC")
private List<ScenarioCharacterEntity> scenarioCharacters = new ArrayList<>();
/**
@ -102,15 +102,19 @@ public class ScenarioEntity extends PanacheEntityBase {
/**
* Returns the list of scenariocharacter links (user character first, then AI by position).
* Never returns null; scenarios loaded from DB without characters (e.g. seed data) get an empty list.
*/
public List<ScenarioCharacterEntity> getScenarioCharacters() {
if (scenarioCharacters == null) {
scenarioCharacters = new ArrayList<>();
}
return scenarioCharacters;
}
/**
* Sets the list of scenariocharacter links.
* Sets the list of scenariocharacter links. Null is treated as empty list.
*/
public void setScenarioCharacters(final List<ScenarioCharacterEntity> scenarioCharacters) {
this.scenarioCharacters = scenarioCharacters;
this.scenarioCharacters = scenarioCharacters != null ? scenarioCharacters : new ArrayList<>();
}
}

View File

@ -3,12 +3,14 @@ package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.CharacterService;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CharacterListResponse;
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
import de.neitzel.roleplay.generated.api.CharactersApi;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
import java.util.NoSuchElementException;
import java.util.UUID;
/**
@ -36,4 +38,26 @@ public class CharactersResource implements CharactersApi {
return characterService.getCharacter(characterId)
.orElseThrow(() -> new NotFoundException("No character found with id: " + characterId));
}
@Override
public CharacterDefinition createCharacter(final CreateCharacterRequest createCharacterRequest) {
return characterService.create(createCharacterRequest);
}
@Override
public CharacterDefinition updateCharacter(final UUID characterId,
final CreateCharacterRequest createCharacterRequest) {
try {
return characterService.update(characterId, createCharacterRequest);
} catch (final NoSuchElementException e) {
throw new NotFoundException("No character found with id: " + characterId);
}
}
@Override
public void deleteCharacter(final UUID characterId) {
if (!characterService.delete(characterId)) {
throw new NotFoundException("No character found with id: " + characterId);
}
}
}

View File

@ -1,14 +1,18 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.ScenarioService;
import de.neitzel.roleplay.fascade.model.CreateScenarioRequest;
import de.neitzel.roleplay.fascade.model.ScenarioListResponse;
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
import de.neitzel.roleplay.generated.api.ScenariosApi;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.NotFoundException;
import java.util.NoSuchElementException;
import java.util.UUID;
/**
@ -36,4 +40,32 @@ public class ScenariosResource implements ScenariosApi {
return scenarioService.getScenarioAsSetup(scenarioId)
.orElseThrow(() -> new NotFoundException("No scenario found with id: " + scenarioId));
}
@Override
public ScenarioSummary createScenario(final CreateScenarioRequest createScenarioRequest) {
try {
return scenarioService.create(createScenarioRequest);
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
@Override
public ScenarioSummary updateScenario(final UUID scenarioId,
final CreateScenarioRequest createScenarioRequest) {
try {
return scenarioService.update(scenarioId, createScenarioRequest);
} catch (final NoSuchElementException e) {
throw new NotFoundException("No scenario found with id: " + scenarioId);
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
@Override
public void deleteScenario(final UUID scenarioId) {
if (!scenarioService.delete(scenarioId)) {
throw new NotFoundException("No scenario found with id: " + scenarioId);
}
}
}

View File

@ -1,9 +1,6 @@
quarkus:
application:
name: roleplay
resteasy-reactive:
scan:
paths: de.neitzel.roleplay.fascade,de.neitzel.roleplay.business,de.neitzel.roleplay.common,de.neitzel.roleplay.data
http:
root-path: /
datasource:
@ -12,9 +9,6 @@ quarkus:
url: jdbc:h2:mem:roleplay;DB_CLOSE_DELAY=-1
username: sa
password: ""
hibernate-orm:
database:
generation: none
liquibase:
change-log: db/migration/changelog.xml
migrate-at-start: true

View File

@ -61,6 +61,31 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ScenarioListResponse'
post:
operationId: createScenario
summary: Create a scenario
description: Creates a new scenario with optional character slots. Server generates UUID.
tags:
- scenarios
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateScenarioRequest'
responses:
"201":
description: Scenario created.
content:
application/json:
schema:
$ref: '#/components/schemas/ScenarioSummary'
"400":
description: Invalid request body or referenced character not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/scenarios/{scenarioId}:
get:
@ -84,6 +109,56 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
operationId: updateScenario
summary: Update a scenario
description: Full replace. Replaces all character slots.
tags:
- scenarios
parameters:
- $ref: '#/components/parameters/ScenarioId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateScenarioRequest'
responses:
"200":
description: Scenario updated.
content:
application/json:
schema:
$ref: '#/components/schemas/ScenarioSummary'
"400":
description: Invalid request body or referenced character not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"404":
description: Scenario not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
operationId: deleteScenario
summary: Delete a scenario
description: Removes the scenario template.
tags:
- scenarios
parameters:
- $ref: '#/components/parameters/ScenarioId'
responses:
"204":
description: Scenario deleted.
"404":
description: Scenario not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/characters:
get:
@ -99,6 +174,31 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CharacterListResponse'
post:
operationId: createCharacter
summary: Create a character
description: Creates a new character template. Server generates UUID if id is omitted.
tags:
- characters
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateCharacterRequest'
responses:
"201":
description: Character created.
content:
application/json:
schema:
$ref: '#/components/schemas/CharacterDefinition'
"400":
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/characters/{characterId}:
get:
@ -122,6 +222,56 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
operationId: updateCharacter
summary: Update a character
description: Full replace of the character. All fields required except optional ones.
tags:
- characters
parameters:
- $ref: '#/components/parameters/CharacterId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateCharacterRequest'
responses:
"200":
description: Character updated.
content:
application/json:
schema:
$ref: '#/components/schemas/CharacterDefinition'
"400":
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"404":
description: Character not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
operationId: deleteCharacter
summary: Delete a character
description: Removes the character template. Scenarios referencing it may need updating.
tags:
- characters
parameters:
- $ref: '#/components/parameters/CharacterId'
responses:
"204":
description: Character deleted.
"404":
description: Character not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/sessions:
post:
@ -401,6 +551,80 @@ components:
required:
- characters
CreateCharacterRequest:
type: object
description: Request body for creating or updating a character. id optional on create (server generates UUID).
properties:
id:
type: string
format: uuid
description: Optional on create; server generates if omitted.
name:
type: string
description: Display name.
role:
type: string
description: Narrative role.
backstory:
type: string
description: Character background.
speakingStyle:
type: string
description: How the character speaks.
personalityTraits:
type: array
items:
type: string
description: List of personality traits.
goals:
type: array
items:
type: string
description: Character goals.
required:
- name
- role
ScenarioCharacterSlot:
type: object
description: Assignment of a character to a scenario slot (user or AI, with position).
properties:
characterId:
type: string
format: uuid
description: Reference to a saved character.
isUserCharacter:
type: boolean
description: True if this slot is the player character.
position:
type: integer
description: Order of the character (0 for user, 1+ for AI).
required:
- characterId
- isUserCharacter
- position
CreateScenarioRequest:
type: object
description: Request body for creating or updating a scenario.
properties:
name:
type: string
description: Human-readable scenario name.
setting:
type: string
description: Place, time, and atmosphere.
initialConflict:
type: string
description: The hook or starting conflict.
characterSlots:
type: array
items:
$ref: '#/components/schemas/ScenarioCharacterSlot'
description: Assigned characters (one user, ordered AI).
required:
- name
ScenarioSetup:
type: object
description: |

View File

@ -1,17 +1,24 @@
import {BrowserRouter, Route, Routes} from 'react-router-dom'
import AppLayout from './components/AppLayout'
import StartPage from './pages/StartPage'
import SessionPage from './pages/SessionPage'
import ScenariosPage from './pages/ScenariosPage'
import CharactersPage from './pages/CharactersPage'
/**
* Root application component. Sets up client-side routing between the model
* selection page and the active session page.
* Root application component. Sets up client-side routing with app shell:
* Home (start flow), Scenarios, Characters, and Session.
*/
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppLayout/>}>
<Route path="/" element={<StartPage/>}/>
<Route path="/scenarios" element={<ScenariosPage/>}/>
<Route path="/characters" element={<CharactersPage/>}/>
<Route path="/session/:sessionId" element={<SessionPage/>}/>
</Route>
</Routes>
</BrowserRouter>
)

View File

@ -0,0 +1,54 @@
import {AppBar, Box, Toolbar, Typography} from '@mui/material'
import {Link as RouterLink, Outlet, useLocation} from 'react-router-dom'
import {Link} from '@mui/material'
/**
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
*/
export default function AppLayout() {
const location = useLocation()
const navItems = [
{ to: '/', label: 'Home' },
{ to: '/scenarios', label: 'Scenarios' },
{ to: '/characters', label: 'Characters' },
]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static" elevation={0}>
<Toolbar variant="dense">
<Typography
component={RouterLink}
to="/"
variant="h6"
sx={{
flexGrow: 1,
textDecoration: 'none',
color: 'inherit',
fontWeight: 700,
}}
>
RolePlay
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{navItems.map(({ to, label }) => (
<Link
key={to}
component={RouterLink}
to={to}
underline={location.pathname === to ? 'always' : 'hover'}
color="inherit"
sx={{ px: 1.5, py: 0.5, borderRadius: 1 }}
>
{label}
</Link>
))}
</Box>
</Toolbar>
</AppBar>
<Box component="main" sx={{ flexGrow: 1, p: 2 }}>
<Outlet />
</Box>
</Box>
)
}

View File

@ -0,0 +1,308 @@
import {useCallback, useEffect, useState} from 'react'
import {
Alert,
Box,
Button,
Card,
CardContent,
CardActions,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import type {CharacterDefinition, CreateCharacterRequest} from '../api/generated/index'
import {CharactersApi, Configuration} from '../api/generated/index'
const API_BASE = '/api/v1'
const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE}))
/** Parse comma- or newline-separated string into trimmed non-empty strings. */
function parseList(value: string | undefined): string[] {
if (!value || !value.trim()) return []
return value
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean)
}
/** Format string array for display in a text field. */
function formatList(arr: string[] | undefined): string {
return arr?.length ? arr.join(', ') : ''
}
/**
* Characters management page. Lists characters as cards with New/Edit/Delete.
*/
export default function CharactersPage() {
const [characters, setCharacters] = useState<CharacterDefinition[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [formName, setFormName] = useState('')
const [formRole, setFormRole] = useState('')
const [formBackstory, setFormBackstory] = useState('')
const [formSpeakingStyle, setFormSpeakingStyle] = useState('')
const [formPersonalityTraits, setFormPersonalityTraits] = useState('')
const [formGoals, setFormGoals] = useState('')
const loadCharacters = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await charactersApi.listCharacters()
setCharacters(res.characters ?? [])
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load characters')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadCharacters()
}, [loadCharacters])
const openCreate = () => {
setEditingId(null)
setFormName('')
setFormRole('')
setFormBackstory('')
setFormSpeakingStyle('')
setFormPersonalityTraits('')
setFormGoals('')
setDialogOpen(true)
}
const openEdit = (c: CharacterDefinition) => {
setEditingId(c.id)
setFormName(c.name)
setFormRole(c.role)
setFormBackstory(c.backstory ?? '')
setFormSpeakingStyle(c.speakingStyle ?? '')
setFormPersonalityTraits(formatList(c.personalityTraits))
setFormGoals(formatList(c.goals))
setDialogOpen(true)
}
const closeDialog = () => {
setDialogOpen(false)
setEditingId(null)
}
const handleSave = async () => {
if (!formName.trim() || !formRole.trim()) return
setSaving(true)
setError(null)
try {
const body: CreateCharacterRequest = {
name: formName.trim(),
role: formRole.trim(),
backstory: formBackstory.trim() || undefined,
speakingStyle: formSpeakingStyle.trim() || undefined,
personalityTraits: parseList(formPersonalityTraits).length
? parseList(formPersonalityTraits)
: undefined,
goals: parseList(formGoals).length ? parseList(formGoals) : undefined,
}
if (editingId) {
body.id = editingId
await charactersApi.updateCharacter({characterId: editingId, createCharacterRequest: body})
} else {
await charactersApi.createCharacter({createCharacterRequest: body})
}
closeDialog()
await loadCharacters()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to save character')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
setError(null)
try {
await charactersApi.deleteCharacter({characterId: id})
setDeleteConfirmId(null)
await loadCharacters()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete character')
}
}
if (loading) {
return (
<Box sx={{display: 'flex', justifyContent: 'center', py: 4}}>
<CircularProgress/>
</Box>
)
}
return (
<>
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2}}>
<Typography variant="h4">Characters</Typography>
<Button variant="contained" startIcon={<AddIcon/>} onClick={openCreate}>
New character
</Button>
</Box>
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{mb: 2}}>
{error}
</Alert>
)}
{characters.length === 0 ? (
<Alert
severity="info"
action={
<Button color="inherit" size="small" onClick={openCreate}>
Create first character
</Button>
}
>
No characters yet. Create your first character to use in scenarios.
</Alert>
) : (
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 2}}>
{characters.map((c) => (
<Card key={c.id} sx={{minWidth: 280, maxWidth: 360}}>
<CardContent>
<Typography variant="h6">{c.name}</Typography>
<Typography variant="body2" color="text.secondary">
{c.role}
</Typography>
{c.backstory && (
<Typography
variant="body2"
sx={{mt: 1}}
noWrap
>
{c.backstory}
</Typography>
)}
</CardContent>
<CardActions disableSpacing>
<IconButton
aria-label="Edit"
onClick={() => openEdit(c)}
size="small"
>
<EditIcon/>
</IconButton>
<IconButton
aria-label="Delete"
onClick={() => setDeleteConfirmId(c.id)}
size="small"
color="error"
>
<DeleteIcon/>
</IconButton>
</CardActions>
</Card>
))}
</Box>
)}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{editingId ? 'Edit character' : 'New character'}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Name"
required
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
<TextField
margin="dense"
label="Role"
required
fullWidth
value={formRole}
onChange={(e) => setFormRole(e.target.value)}
/>
<TextField
margin="dense"
label="Backstory"
fullWidth
multiline
minRows={2}
value={formBackstory}
onChange={(e) => setFormBackstory(e.target.value)}
/>
<TextField
margin="dense"
label="Speaking style"
fullWidth
value={formSpeakingStyle}
onChange={(e) => setFormSpeakingStyle(e.target.value)}
/>
<TextField
margin="dense"
label="Personality traits (comma-separated)"
fullWidth
value={formPersonalityTraits}
onChange={(e) => setFormPersonalityTraits(e.target.value)}
placeholder="e.g. brave, stern, witty"
/>
<TextField
margin="dense"
label="Goals (comma-separated)"
fullWidth
value={formGoals}
onChange={(e) => setFormGoals(e.target.value)}
placeholder="e.g. Find the treasure, Protect the crew"
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving || !formName.trim() || !formRole.trim()}
>
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={deleteConfirmId !== null}
onClose={() => setDeleteConfirmId(null)}
>
<DialogTitle>Delete character?</DialogTitle>
<DialogContent>
<Typography>
This cannot be undone. Scenarios using this character may need to be updated.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
>
Delete
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -0,0 +1,356 @@
import {useCallback, useEffect, useState} from 'react'
import {useNavigate} from 'react-router-dom'
import {
Alert,
Box,
Button,
Card,
CardContent,
CardActions,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import type {
CharacterDefinition,
CreateScenarioRequest,
ScenarioSetup,
ScenarioSummary,
} from '../api/generated/index'
import {CharactersApi, Configuration, ScenariosApi} from '../api/generated/index'
const API_BASE = '/api/v1'
const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE}))
const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE}))
/**
* Scenarios management page. List cards with New/Edit/Delete/Start; form with character assignment.
*/
export default function ScenariosPage() {
const navigate = useNavigate()
const [scenarios, setScenarios] = useState<ScenarioSummary[]>([])
const [characters, setCharacters] = useState<CharacterDefinition[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [formName, setFormName] = useState('')
const [formSetting, setFormSetting] = useState('')
const [formInitialConflict, setFormInitialConflict] = useState('')
const [formUserCharacterId, setFormUserCharacterId] = useState<string>('')
const [formAiCharacterIds, setFormAiCharacterIds] = useState<string[]>([])
const loadScenarios = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await scenariosApi.listScenarios()
setScenarios(res.scenarios ?? [])
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load scenarios')
} finally {
setLoading(false)
}
}, [])
const loadCharacters = useCallback(async () => {
try {
const res = await charactersApi.listCharacters()
setCharacters(res.characters ?? [])
} catch {
setCharacters([])
}
}, [])
useEffect(() => {
loadScenarios()
}, [loadScenarios])
useEffect(() => {
loadCharacters()
}, [loadCharacters])
const openCreate = () => {
setEditingId(null)
setFormName('')
setFormSetting('')
setFormInitialConflict('')
setFormUserCharacterId('')
setFormAiCharacterIds([])
setDialogOpen(true)
}
const openEdit = async (s: ScenarioSummary) => {
setEditingId(s.id)
setFormName(s.name ?? '')
setFormSetting(s.setting ?? '')
setFormInitialConflict(s.initialConflict ?? '')
setFormUserCharacterId('')
setFormAiCharacterIds([])
setDialogOpen(true)
try {
const setup: ScenarioSetup = await scenariosApi.getScenario({scenarioId: s.id})
setFormSetting(setup.setting ?? formSetting)
setFormInitialConflict(setup.initialConflict ?? formInitialConflict)
if (setup.userCharacter?.id) setFormUserCharacterId(setup.userCharacter.id)
if (setup.aiCharacters?.length)
setFormAiCharacterIds(setup.aiCharacters.map((c) => c.id))
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load scenario')
}
}
const closeDialog = () => {
setDialogOpen(false)
setEditingId(null)
}
const buildCharacterSlots = (): Array<{characterId: string; isUserCharacter: boolean; position: number}> => {
const slots: Array<{characterId: string; isUserCharacter: boolean; position: number}> = []
if (formUserCharacterId) {
slots.push({characterId: formUserCharacterId, isUserCharacter: true, position: 0})
}
formAiCharacterIds.forEach((id, i) => {
slots.push({characterId: id, isUserCharacter: false, position: i + 1})
})
return slots
}
const handleSave = async () => {
if (!formName.trim()) return
setSaving(true)
setError(null)
try {
const body: CreateScenarioRequest = {
name: formName.trim(),
setting: formSetting.trim() || undefined,
initialConflict: formInitialConflict.trim() || undefined,
characterSlots: buildCharacterSlots(), // always send array so backend receives list
}
if (editingId) {
await scenariosApi.updateScenario({scenarioId: editingId, createScenarioRequest: body})
} else {
await scenariosApi.createScenario({createScenarioRequest: body})
}
closeDialog()
await loadScenarios()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to save scenario')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
setError(null)
try {
await scenariosApi.deleteScenario({scenarioId: id})
setDeleteConfirmId(null)
await loadScenarios()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete scenario')
}
}
const handleStart = (scenarioId: string) => {
navigate('/', {state: {scenarioId}})
}
if (loading) {
return (
<Box sx={{display: 'flex', justifyContent: 'center', py: 4}}>
<CircularProgress/>
</Box>
)
}
return (
<>
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2}}>
<Typography variant="h4">Scenarios</Typography>
<Button variant="contained" startIcon={<AddIcon/>} onClick={openCreate}>
New scenario
</Button>
</Box>
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{mb: 2}}>
{error}
</Alert>
)}
{scenarios.length === 0 ? (
<Alert
severity="info"
action={
<Button color="inherit" size="small" onClick={openCreate}>
Create first scenario
</Button>
}
>
No scenarios yet. Create your first scenario to start a story.
</Alert>
) : (
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 2}}>
{scenarios.map((s) => (
<Card key={s.id} sx={{minWidth: 280, maxWidth: 360}}>
<CardContent>
<Typography variant="h6">{s.name}</Typography>
{s.setting && (
<Typography variant="body2" color="text.secondary" noWrap>
{s.setting}
</Typography>
)}
{s.initialConflict && (
<Typography variant="body2" sx={{mt: 0.5}} noWrap>
{s.initialConflict}
</Typography>
)}
</CardContent>
<CardActions disableSpacing>
<Button
size="small"
startIcon={<PlayArrowIcon/>}
onClick={() => handleStart(s.id)}
>
Start
</Button>
<IconButton aria-label="Edit" onClick={() => openEdit(s)} size="small">
<EditIcon/>
</IconButton>
<IconButton
aria-label="Delete"
onClick={() => setDeleteConfirmId(s.id)}
size="small"
color="error"
>
<DeleteIcon/>
</IconButton>
</CardActions>
</Card>
))}
</Box>
)}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{editingId ? 'Edit scenario' : 'New scenario'}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Name"
required
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
<TextField
margin="dense"
label="Setting"
fullWidth
multiline
minRows={2}
value={formSetting}
onChange={(e) => setFormSetting(e.target.value)}
placeholder="Place, time, and atmosphere"
/>
<TextField
margin="dense"
label="Initial conflict"
fullWidth
multiline
minRows={2}
value={formInitialConflict}
onChange={(e) => setFormInitialConflict(e.target.value)}
/>
<FormControl fullWidth margin="dense" size="small">
<InputLabel>User character</InputLabel>
<Select
value={formUserCharacterId}
label="User character"
onChange={(e) => setFormUserCharacterId(e.target.value)}
>
<MenuItem value=""> None </MenuItem>
{characters.map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name} ({c.role})
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="dense" size="small">
<InputLabel>AI characters</InputLabel>
<Select
multiple
value={formAiCharacterIds}
label="AI characters"
onChange={(e) =>
setFormAiCharacterIds(
typeof e.target.value === 'string'
? []
: (e.target.value as string[])
)
}
renderValue={(selected) =>
selected
.map((id) => characters.find((c) => c.id === id)?.name ?? id)
.join(', ')
}
>
{characters
.filter((c) => c.id !== formUserCharacterId)
.map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name} ({c.role})
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving || !formName.trim()}
>
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={deleteConfirmId !== null} onClose={() => setDeleteConfirmId(null)}>
<DialogTitle>Delete scenario?</DialogTitle>
<DialogContent>
<Typography>This cannot be undone.</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
>
Delete
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -1,5 +1,5 @@
import {useEffect, useState} from 'react'
import {useNavigate} from 'react-router-dom'
import {useEffect, useRef, useState} from 'react'
import {useLocation, useNavigate} from 'react-router-dom'
import {
Accordion,
AccordionDetails,
@ -59,6 +59,8 @@ const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE}))
*/
export default function StartPage() {
const navigate = useNavigate()
const location = useLocation()
const deepLinkApplied = useRef(false)
const [models, setModels] = useState<ModelInfo[]>([])
const [selectedModel, setSelectedModel] = useState<string>('')
@ -100,6 +102,25 @@ export default function StartPage() {
.finally(() => setLoading(false))
}, [])
/** When arriving from Scenarios "Start" with state.scenarioId, pre-select that scenario and load its setup. */
useEffect(() => {
const state = location.state as { scenarioId?: string } | null
const id = state?.scenarioId
if (!id || deepLinkApplied.current || scenarios.length === 0) return
if (!scenarios.some((s) => s.id === id)) return
deepLinkApplied.current = true
setSelectedScenarioId(id)
scenariosApi
.getScenario({scenarioId: id})
.then((setup) => {
setSetting(setup.setting ?? '')
setInitialConflict(setup.initialConflict ?? '')
setUserCharacter(setup.userCharacter ?? null)
setAiCharacters(setup.aiCharacters ?? [])
})
.catch(() => setError('Could not load scenario.'))
}, [scenarios, location.state])
const handleModelChange = (event: SelectChangeEvent) => {
setSelectedModel(event.target.value)
}

View File

@ -0,0 +1,131 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.data.CharacterEntity;
import de.neitzel.roleplay.data.CharacterRepository;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link CharacterService}.
*/
@ExtendWith(MockitoExtension.class)
class CharacterServiceTest {
@Mock
private CharacterRepository characterRepository;
private CharacterService characterService;
@BeforeEach
void setUp() {
characterService = new CharacterService(characterRepository);
}
@Test
void createPersistsWithGeneratedIdAndReturnsDefinition() {
CreateCharacterRequest request = new CreateCharacterRequest("Captain Morgan", "captain");
request.setBackstory("Sea veteran");
request.setSpeakingStyle("Gruff");
request.setPersonalityTraits(List.of("brave", "stern"));
request.setGoals(List.of("Find the treasure"));
CharacterDefinition result = characterService.create(request);
assertNotNull(result.getId());
assertEquals("Captain Morgan", result.getName());
assertEquals("captain", result.getRole());
assertEquals("Sea veteran", result.getBackstory());
assertEquals("Gruff", result.getSpeakingStyle());
assertEquals(List.of("brave", "stern"), result.getPersonalityTraits());
assertEquals(List.of("Find the treasure"), result.getGoals());
ArgumentCaptor<CharacterEntity> captor = ArgumentCaptor.forClass(CharacterEntity.class);
verify(characterRepository).persist(captor.capture());
assertEquals("Captain Morgan", captor.getValue().getName());
assertEquals("captain", captor.getValue().getRole());
}
@Test
void createUsesRequestIdWhenProvided() {
UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101");
CreateCharacterRequest request = new CreateCharacterRequest("Name", "role");
request.setId(id);
CharacterDefinition result = characterService.create(request);
assertEquals(id.toString(), result.getId());
ArgumentCaptor<CharacterEntity> captor = ArgumentCaptor.forClass(CharacterEntity.class);
verify(characterRepository).persist(captor.capture());
assertEquals(id, captor.getValue().getId());
}
@Test
void updateReplacesAndPersists() {
UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101");
CharacterEntity existing = new CharacterEntity();
existing.setId(id);
existing.setName("Old");
existing.setRole("oldRole");
when(characterRepository.findByIdOptional(id)).thenReturn(existing);
CreateCharacterRequest request = new CreateCharacterRequest("New Name", "newRole");
request.setBackstory("Updated backstory");
CharacterDefinition result = characterService.update(id, request);
assertEquals(id.toString(), result.getId());
assertEquals("New Name", result.getName());
assertEquals("newRole", result.getRole());
assertEquals("Updated backstory", result.getBackstory());
verify(characterRepository).persist(existing);
assertEquals("New Name", existing.getName());
}
@Test
void updateThrowsWhenCharacterNotFound() {
UUID id = UUID.randomUUID();
when(characterRepository.findByIdOptional(id)).thenReturn(null);
CreateCharacterRequest request = new CreateCharacterRequest("Name", "role");
assertThrows(java.util.NoSuchElementException.class,
() -> characterService.update(id, request));
}
@Test
void deleteReturnsTrueAndDeletesWhenFound() {
UUID id = UUID.randomUUID();
CharacterEntity entity = new CharacterEntity();
entity.setId(id);
when(characterRepository.findByIdOptional(id)).thenReturn(entity);
boolean result = characterService.delete(id);
assertTrue(result);
verify(characterRepository).delete(entity);
}
@Test
void deleteReturnsFalseWhenNotFound() {
UUID id = UUID.randomUUID();
when(characterRepository.findByIdOptional(id)).thenReturn(null);
boolean result = characterService.delete(id);
assertTrue(!result);
}
}

View File

@ -1,14 +1,20 @@
package de.neitzel.roleplay.business;
import jakarta.persistence.EntityManager;
import de.neitzel.roleplay.data.CharacterEntity;
import de.neitzel.roleplay.data.CharacterRepository;
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
import de.neitzel.roleplay.data.ScenarioEntity;
import de.neitzel.roleplay.data.ScenarioRepository;
import de.neitzel.roleplay.fascade.model.CreateScenarioRequest;
import de.neitzel.roleplay.fascade.model.ScenarioCharacterSlot;
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ -18,7 +24,9 @@ import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@ -30,11 +38,17 @@ class ScenarioServiceTest {
@Mock
private ScenarioRepository scenarioRepository;
@Mock
private CharacterRepository characterRepository;
@Mock
private EntityManager entityManager;
private ScenarioService scenarioService;
@BeforeEach
void setUp() {
scenarioService = new ScenarioService(scenarioRepository);
scenarioService = new ScenarioService(scenarioRepository, characterRepository, entityManager);
}
@Test
@ -124,4 +138,105 @@ class ScenarioServiceTest {
assertEquals("Captain Morgan", setup.getAiCharacters().get(0).getName());
assertEquals("captain", setup.getAiCharacters().get(0).getRole());
}
@Test
void createScenarioPersistsAndReturnsSummary() {
UUID charId = UUID.fromString("11111111-1111-1111-1111-111111111101");
CharacterEntity character = new CharacterEntity();
character.setId(charId);
character.setName("Detective");
character.setRole("detective");
when(characterRepository.findByIdOptional(charId)).thenReturn(character);
ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, true, 0);
CreateScenarioRequest request = new CreateScenarioRequest("Mystery");
request.setSetting("A harbour");
request.setInitialConflict("Noises");
request.setCharacterSlots(List.of(slot));
ScenarioSummary result = scenarioService.create(request);
assertNotNull(result.getId());
assertEquals("Mystery", result.getName());
assertEquals("A harbour", result.getSetting());
assertEquals("Noises", result.getInitialConflict());
ArgumentCaptor<ScenarioEntity> captor = ArgumentCaptor.forClass(ScenarioEntity.class);
verify(scenarioRepository).persist(captor.capture());
assertEquals(1, captor.getValue().getScenarioCharacters().size());
assertTrue(captor.getValue().getScenarioCharacters().get(0).isUserCharacter());
}
@Test
void createScenarioThrowsWhenCharacterNotFound() {
UUID charId = UUID.randomUUID();
when(characterRepository.findByIdOptional(charId)).thenReturn(null);
ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, false, 1);
CreateScenarioRequest request = new CreateScenarioRequest("Mystery");
request.setCharacterSlots(List.of(slot));
assertThrows(IllegalArgumentException.class, () -> scenarioService.create(request));
}
@Test
void updateScenarioReplacesAndPersists() {
UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201");
UUID charId = UUID.fromString("11111111-1111-1111-1111-111111111101");
CharacterEntity character = new CharacterEntity();
character.setId(charId);
character.setName("Detective");
character.setRole("detective");
ScenarioEntity existing = new ScenarioEntity();
existing.setId(scenarioId);
existing.setName("Old");
existing.setScenarioCharacters(new java.util.ArrayList<>());
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(existing);
when(characterRepository.findByIdOptional(charId)).thenReturn(character);
ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, true, 0);
CreateScenarioRequest request = new CreateScenarioRequest("Updated");
request.setSetting("New setting");
request.setCharacterSlots(List.of(slot));
ScenarioSummary result = scenarioService.update(scenarioId, request);
assertEquals(scenarioId, result.getId());
assertEquals("Updated", result.getName());
assertEquals("New setting", result.getSetting());
verify(scenarioRepository).persist(existing);
assertEquals(1, existing.getScenarioCharacters().size());
}
@Test
void updateScenarioThrowsWhenScenarioNotFound() {
UUID scenarioId = UUID.randomUUID();
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(null);
CreateScenarioRequest request = new CreateScenarioRequest("Name");
assertThrows(java.util.NoSuchElementException.class,
() -> scenarioService.update(scenarioId, request));
}
@Test
void deleteScenarioReturnsTrueWhenFound() {
UUID scenarioId = UUID.randomUUID();
ScenarioEntity scenario = new ScenarioEntity();
scenario.setId(scenarioId);
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(scenario);
boolean result = scenarioService.delete(scenarioId);
assertTrue(result);
verify(scenarioRepository).delete(scenario);
}
@Test
void deleteScenarioReturnsFalseWhenNotFound() {
UUID scenarioId = UUID.randomUUID();
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(null);
boolean result = scenarioService.delete(scenarioId);
assertTrue(!result);
}
}