Compare commits

...

2 Commits

Author SHA1 Message Date
560c07d5c3 Changement du logo pour l'application et changement du nom en "DM Loremind" pour le tittre de l'application.
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m17s
Build & Push Images / build (brain) (push) Successful in 1m13s
E2E Tests / e2e (push) Failing after 5m22s
Build & Push Images / build (core) (push) Successful in 3m25s
Build & Push Images / build-switcher (push) Successful in 51s
Build & Push Images / build (web) (push) Successful in 1m57s
2026-06-19 10:00:13 +02:00
5aa08a3a27 Correction du worflow release : on ne fait plus les tests (évite le doublons de test avec le ci.yml).
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 30s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m22s
Build & Push Images / build (brain) (push) Successful in 1m16s
E2E Tests / e2e (push) Failing after 5m24s
Build & Push Images / build (core) (push) Successful in 3m5s
Build & Push Images / build-switcher (push) Successful in 50s
Build & Push Images / build (web) (push) Successful in 1m56s
Correction de tests coté java + config
Suppression de classes inutilisées coté Angular
2026-06-19 00:36:30 +02:00
50 changed files with 201 additions and 274 deletions

View File

@@ -12,71 +12,10 @@ env:
GHCR_NAMESPACE: igmlcreation GHCR_NAMESPACE: igmlcreation
jobs: jobs:
# GATE : aucune image n'est build/push tant que les 3 suites unitaires # NB : pas de job de test ici. Le gate qualité vit dans ci.yml (push main + PR) ;
# (Java / Python / Angular) ne passent pas. Un tag posé sur un commit aux # avec la branch protection Gitea (checks requis sur main), on ne tague donc que
# tests rouges ne publiera donc PAS d'images. # du code déjà vert. release.yml ne fait que builder/pousser les images.
tests:
runs-on: ubuntu-latest
# Base PostgreSQL réelle pour les tests Core (joignable par le nom `postgres`
# depuis le conteneur du job Gitea).
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: loremind_test
POSTGRES_USER: loremind_test
POSTGRES_PASSWORD: loremind_test
options: >-
--health-cmd "pg_isready -U loremind_test -d loremind_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Core — mvn test (+ JaCoCo check)
working-directory: core
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/loremind_test
SPRING_DATASOURCE_USERNAME: loremind_test
SPRING_DATASOURCE_PASSWORD: loremind_test
run: |
chmod +x ./mvnw
./mvnw -B test
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: brain/requirements-dev.txt
- name: Brain — pytest (+ couverture)
working-directory: brain
run: |
pip install -r requirements-dev.txt
pytest --cov=app --cov-report=term-missing --cov-fail-under=50
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Web — vitest (+ couverture)
working-directory: web
run: |
npm ci --no-audit --no-fund
npm run test:unit:coverage
build: build:
needs: tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -157,7 +96,6 @@ jobs:
# donc uniquement sur les releases stables — pas la peine de re-publier # donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre. # une variante beta du switcher, c'est une infrastructure neutre.
build-switcher: build-switcher:
needs: tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.16.2", version="0.17.0",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.16.2</version> <version>0.17.0</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -152,6 +152,45 @@
<build> <build>
<plugins> <plugins>
<!-- Declare EXPLICITEMENT le processor d'annotations Lombok : sinon javac
avertit que l'annotation processing implicite (processeur trouve sur le
classpath sans etre declare) sera desactivee dans un futur JDK. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- Expose le chemin du jar mockito-core dans la propriete
${org.mockito:mockito-core:jar}, consommee par surefire ci-dessous. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Charge Mockito comme un VRAI -javaagent (au lieu de l'auto-attachement
dynamique de byte-buddy, qui imprime un avertissement et sera interdit
dans un futur JDK). `@{argLine}` preserve l'argLine pose par JaCoCo. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>

View File

@@ -225,7 +225,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
private Map<String, String> toStringMap(JsonNode object) { private Map<String, String> toStringMap(JsonNode object) {
Map<String, String> out = new LinkedHashMap<>(); Map<String, String> out = new LinkedHashMap<>();
if (object != null && object.isObject()) { if (object != null && object.isObject()) {
object.fields().forEachRemaining(e -> out.put(e.getKey(), e.getValue().asText())); object.properties().forEach(e -> out.put(e.getKey(), e.getValue().asText()));
} }
return out; return out;
} }

View File

@@ -37,8 +37,8 @@ public class RestTemplateConfig {
@Value("${brain.timeout-seconds}") long timeoutSeconds, @Value("${brain.timeout-seconds}") long timeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) { @Value("${brain.internal-secret}") String internalSecret) {
return builder return builder
.setConnectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(timeoutSeconds)) .readTimeout(Duration.ofSeconds(timeoutSeconds))
.additionalInterceptors((request, body, execution) -> { .additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
@@ -61,8 +61,8 @@ public class RestTemplateConfig {
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds, @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) { @Value("${brain.internal-secret}") String internalSecret) {
return builder return builder
.setConnectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(importTimeoutSeconds)) .readTimeout(Duration.ofSeconds(importTimeoutSeconds))
.additionalInterceptors((request, body, execution) -> { .additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);

View File

@@ -50,8 +50,8 @@ public class DesktopUpdateService {
@Value("${desktop.update.releases-api-url:https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest}") String releasesApiUrl, @Value("${desktop.update.releases-api-url:https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest}") String releasesApiUrl,
@Nullable BuildProperties buildProperties) { @Nullable BuildProperties buildProperties) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .connectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10)) .readTimeout(Duration.ofSeconds(10))
.build(); .build();
this.enabled = enabled; this.enabled = enabled;
this.releasesApiUrl = releasesApiUrl; this.releasesApiUrl = releasesApiUrl;

View File

@@ -20,6 +20,9 @@ import java.awt.RenderingHints;
import java.awt.SystemTray; import java.awt.SystemTray;
import java.awt.TrayIcon; import java.awt.TrayIcon;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
/** /**
* Icone dans la zone de notification (barre des taches) en mode bureau * Icone dans la zone de notification (barre des taches) en mode bureau
@@ -63,7 +66,7 @@ public class SystemTrayManager {
try { try {
popup = new PopupMenu(); popup = new PopupMenu();
MenuItem open = new MenuItem("Ouvrir LoreMind"); MenuItem open = new MenuItem("Ouvrir DM Loremind");
open.addActionListener(e -> DesktopSingleInstance.openAppInBrowser()); open.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
popup.add(open); popup.add(open);
@@ -79,17 +82,17 @@ public class SystemTrayManager {
popup.add(editConfig); popup.add(editConfig);
// Acces au dossier de donnees (~/.loremind : base, images, config…). // Acces au dossier de donnees (~/.loremind : base, images, config…).
MenuItem openFolder = new MenuItem("Ouvrir le dossier LoreMind"); MenuItem openFolder = new MenuItem("Ouvrir le dossier DM Loremind");
openFolder.addActionListener(e -> DesktopSingleInstance.openFolder(DesktopUserConfig.getHomeDir())); openFolder.addActionListener(e -> DesktopSingleInstance.openFolder(DesktopUserConfig.getHomeDir()));
popup.add(openFolder); popup.add(openFolder);
popup.addSeparator(); popup.addSeparator();
MenuItem quit = new MenuItem("Quitter LoreMind"); MenuItem quit = new MenuItem("Quitter DM Loremind");
quit.addActionListener(e -> quit()); quit.addActionListener(e -> quit());
popup.add(quit); popup.add(quit);
trayIcon = new TrayIcon(createIcon(), "LoreMind", popup); trayIcon = new TrayIcon(createIcon(), "DM Loremind", popup);
trayIcon.setImageAutoSize(true); trayIcon.setImageAutoSize(true);
// Double-clic sur l'icone : ouvre l'application dans le navigateur. // Double-clic sur l'icone : ouvre l'application dans le navigateur.
trayIcon.addActionListener(e -> DesktopSingleInstance.openAppInBrowser()); trayIcon.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
@@ -122,7 +125,7 @@ public class SystemTrayManager {
popup.insertSeparator(1); popup.insertSeparator(1);
trayIcon.displayMessage( trayIcon.displayMessage(
"LoreMind — mise a jour disponible", "DM Loremind — mise a jour disponible",
"Version " + info.latestVersion() + " disponible (vous avez " + info.currentVersion() "Version " + info.latestVersion() + " disponible (vous avez " + info.currentVersion()
+ "). Menu de l'icone → Telecharger.", + "). Menu de l'icone → Telecharger.",
TrayIcon.MessageType.INFO); TrayIcon.MessageType.INFO);
@@ -151,19 +154,35 @@ public class SystemTrayManager {
} }
/** /**
* Genere une petite icone (carre arrondi violet « L ») sans dependre d'un * Icone du systray : charge l'image de marque {@code /tray-icon.png} depuis le
* fichier image — robuste quel que soit l'empaquetage. * classpath (embarquee dans le jar). Repli sur une icone dessinee si le fichier
* est absent ou illisible — l'application reste fonctionnelle dans tous les cas.
*/ */
private Image createIcon() { private Image createIcon() {
try (InputStream in = getClass().getResourceAsStream("/tray-icon.png")) {
if (in != null) {
BufferedImage loaded = ImageIO.read(in);
if (loaded != null) {
return loaded;
}
}
} catch (IOException e) {
// ignore : on retombe sur l'icone dessinee ci-dessous
}
return drawFallbackIcon();
}
/** Icone de repli (carre arrondi violet « DM ») si l'image embarquee manque. */
private Image drawFallbackIcon() {
int size = 16; int size = 16;
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics(); Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(0x8B, 0x5C, 0xF6)); // violet de marque LoreMind g.setColor(new Color(0x8B, 0x5C, 0xF6)); // violet de marque
g.fillRoundRect(0, 0, size, size, 5, 5); g.fillRoundRect(0, 0, size, size, 5, 5);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.setFont(new Font("SansSerif", Font.BOLD, 12)); g.setFont(new Font("SansSerif", Font.BOLD, 9));
g.drawString("L", 4, 13); g.drawString("DM", 1, 12);
g.dispose(); g.dispose();
return img; return img;
} }

View File

@@ -40,8 +40,8 @@ public class HttpLicenseRelay implements LicenseRelay {
RestTemplateBuilder builder, RestTemplateBuilder builder,
@Value("${licensing.relay.base-url:}") String baseUrl) { @Value("${licensing.relay.base-url:}") String baseUrl) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .connectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15)) .readTimeout(Duration.ofSeconds(15))
.build(); .build();
this.baseUrl = stripTrailingSlash(baseUrl); this.baseUrl = stripTrailingSlash(baseUrl);
} }

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter; import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -25,6 +26,8 @@ import java.util.Optional;
* la plupart du temps. * la plupart du temps.
*/ */
@Component @Component
// Desactivable (ex: tests) : sans cette propriete (prod), le daemon tourne.
@ConditionalOnProperty(name = "licensing.refresh.enabled", matchIfMissing = true)
public class LicenseRefreshDaemon { public class LicenseRefreshDaemon {
private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class); private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);

View File

@@ -4,6 +4,8 @@ import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs; import io.minio.MakeBucketArgs;
import io.minio.MinioClient; import io.minio.MinioClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -24,6 +26,8 @@ import org.springframework.context.annotation.Configuration;
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true) @ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioConfig { public class MinioConfig {
private static final Logger log = LoggerFactory.getLogger(MinioConfig.class);
@Value("${minio.endpoint}") @Value("${minio.endpoint}")
private String endpoint; private String endpoint;
@@ -66,12 +70,11 @@ public class MinioConfig {
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) { if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
System.out.println("[MinIO] Bucket '" + bucket + "' cree."); log.info("[MinIO] Bucket '{}' cree.", bucket);
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("[MinIO] Initialisation impossible (endpoint=" + endpoint log.warn("[MinIO] Initialisation impossible (endpoint={}). Les uploads d'images "
+ "). Les uploads d'images echoueront tant que MinIO n'est pas joignable. " + "echoueront tant que MinIO n'est pas joignable. Cause : {}", endpoint, e.getMessage());
+ "Cause : " + e.getMessage());
} }
} }
} }

View File

@@ -74,8 +74,8 @@ public class UpdateCheckService {
LicenseService licenseService, LicenseService licenseService,
@Nullable BuildProperties buildProperties) { @Nullable BuildProperties buildProperties) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .connectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15)) .readTimeout(Duration.ofSeconds(15))
.build(); .build();
this.registry = normalizeRegistry(registry); this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv); this.images = parseImages(imagesCsv);

View File

@@ -29,6 +29,9 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=validate spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.format_sql=true
# Fixe explicitement open-in-view (defaut Spring = true) : meme comportement,
# mais supprime l'avertissement "spring.jpa.open-in-view is enabled by default".
spring.jpa.open-in-view=true
# ============================================================================ # ============================================================================
# Flyway : migrations de schema versionnees (src/main/resources/db/migration). # Flyway : migrations de schema versionnees (src/main/resources/db/migration).

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -53,10 +53,10 @@ class AiChatControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@MockBean private StreamChatForLoreUseCase loreUseCase; @MockitoBean private StreamChatForLoreUseCase loreUseCase;
@MockBean private StreamChatForCampaignUseCase campaignUseCase; @MockitoBean private StreamChatForCampaignUseCase campaignUseCase;
@MockBean private StreamChatForSessionUseCase sessionUseCase; @MockitoBean private StreamChatForSessionUseCase sessionUseCase;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach @BeforeEach
void setUp() { void setUp() {

View File

@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -42,8 +42,8 @@ class CampaignAdaptControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private CampaignAdaptService campaignAdaptService; @MockitoBean private CampaignAdaptService campaignAdaptService;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach @BeforeEach
void setUp() { void setUp() {

View File

@@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -58,8 +58,8 @@ class CampaignImportControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@MockBean private CampaignImportService campaignImportService; @MockitoBean private CampaignImportService campaignImportService;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
private static final String CAMPAIGN_ID = "camp-1"; private static final String CAMPAIGN_ID = "camp-1";

View File

@@ -5,7 +5,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -25,7 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class ConfigControllerTest { class ConfigControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates; @MockitoBean private UpdateCheckService updates;
@Test @Test
void getPublicConfig_returns200_updateCheckEnabledTrue() throws Exception { void getPublicConfig_returns200_updateCheckEnabledTrue() throws Exception {

View File

@@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -41,7 +41,7 @@ class ConversationControllerTest {
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@Autowired private ConversationRepository conversationRepository; @Autowired private ConversationRepository conversationRepository;
@MockBean private ConversationTitleGenerator titleGenerator; @MockitoBean private ConversationTitleGenerator titleGenerator;
@Test @Test
void create_withLoreAnchor_returns200() throws Exception { void create_withLoreAnchor_returns200() throws Exception {

View File

@@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -52,8 +52,8 @@ class GameSystemControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@MockBean private RulesPdfImporter rulesPdfImporter; @MockitoBean private RulesPdfImporter rulesPdfImporter;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach @BeforeEach
void setUp() { void setUp() {

View File

@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -42,7 +42,7 @@ class ImageControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private ImageService imageService; @MockitoBean private ImageService imageService;
private Image sampleImage() { private Image sampleImage() {
return Image.builder() return Image.builder()

View File

@@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -49,7 +49,7 @@ class ItemCatalogControllerTest {
@Autowired private ItemCatalogRepository catalogRepository; @Autowired private ItemCatalogRepository catalogRepository;
@Autowired private CampaignRepository campaignRepository; @Autowired private CampaignRepository campaignRepository;
@MockBean private ItemCatalogGenerator generator; @MockitoBean private ItemCatalogGenerator generator;
private String campaignId; private String campaignId;

View File

@@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -54,8 +54,8 @@ class LicenseControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private LicenseService licenseService; @MockitoBean private LicenseService licenseService;
@MockBean private ChannelSwitcherService channelSwitcher; @MockitoBean private ChannelSwitcherService channelSwitcher;
private LicenseSnapshot validSnapshot() { private LicenseSnapshot validSnapshot() {
return new LicenseSnapshot( return new LicenseSnapshot(

View File

@@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -59,9 +59,9 @@ class NotebookControllerTest {
@Autowired private NotebookRepository notebookRepository; @Autowired private NotebookRepository notebookRepository;
@Autowired private CampaignRepository campaignRepository; @Autowired private CampaignRepository campaignRepository;
@MockBean private NotebookIndexer indexer; @MockitoBean private NotebookIndexer indexer;
@MockBean private NotebookChatStreamer chatStreamer; @MockitoBean private NotebookChatStreamer chatStreamer;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
private String campaignId; private String campaignId;

View File

@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Map; import java.util.Map;
@@ -36,7 +36,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class PageGenerationControllerTest { class PageGenerationControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private GeneratePageValuesUseCase generatePageValuesUseCase; @MockitoBean private GeneratePageValuesUseCase generatePageValuesUseCase;
@Test @Test
void generate_returns200_withSuggestions() throws Exception { void generate_returns200_withSuggestions() throws Exception {

View File

@@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -50,7 +50,7 @@ class RandomTableControllerTest {
@Autowired private RandomTableRepository tableRepository; @Autowired private RandomTableRepository tableRepository;
@Autowired private CampaignRepository campaignRepository; @Autowired private CampaignRepository campaignRepository;
@MockBean private RandomTableGenerator generator; @MockitoBean private RandomTableGenerator generator;
private String campaignId; private String campaignId;

View File

@@ -5,7 +5,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -36,7 +36,7 @@ class UpdatesControllerDemoModeTest {
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates; @MockitoBean private UpdateCheckService updates;
@Test @Test
void check_returns403_inDemoMode() throws Exception { void check_returns403_inDemoMode() throws Exception {

View File

@@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -42,7 +42,7 @@ class UpdatesControllerTest {
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates; @MockitoBean private UpdateCheckService updates;
private UpdateStatus sampleUpdate() { private UpdateStatus sampleUpdate() {
return new UpdateStatus(true, true, false, "1.0.0", return new UpdateStatus(true, true, false, "1.0.0",

View File

@@ -10,7 +10,6 @@ spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL. # Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false spring.jpa.show-sql=false
@@ -19,7 +18,7 @@ spring.jpa.show-sql=false
spring.flyway.enabled=false spring.flyway.enabled=false
# Pool Hikari volontairement minuscule en test : la suite cree de nombreux # Pool Hikari volontairement minuscule en test : la suite cree de nombreux
# contextes Spring distincts (combinaisons de @MockBean / @TestPropertySource), # contextes Spring distincts (combinaisons de @MockitoBean / @TestPropertySource),
# tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait # tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait
# les connexions Postgres ("remaining connection slots are reserved"). 2 par # les connexions Postgres ("remaining connection slots are reserved"). 2 par
# contexte suffit (tests sequentiels) et borne le total bien sous max_connections. # contexte suffit (tests sequentiels) et borne le total bien sous max_connections.
@@ -40,3 +39,27 @@ minio.endpoint=http://localhost:9000
minio.access-key=test minio.access-key=test
minio.secret-key=test minio.secret-key=test
minio.bucket=test-bucket minio.bucket=test-bucket
# open-in-view fixe explicitement (defaut true) : supprime l'avertissement au boot.
spring.jpa.open-in-view=true
# Le daemon de refresh de licence (@Scheduled) n'a rien a faire pendant les tests :
# son tick echouait en arriere-plan (table licenses droppee par le create-drop
# partage entre contextes) => stacktraces parasites. On le desactive ici.
licensing.refresh.enabled=false
# --- Sortie de test LISIBLE ---------------------------------------------------
# Pas de banniere Spring (repetee a chaque contexte), et seuls les WARN/ERROR
# remontent : la masse d'INFO de demarrage (Spring/Hibernate/Hikari, "Started
# XxxTest", seeder...) n'apporte rien et noyait les vrais signaux.
spring.main.banner-mode=off
logging.level.root=WARN
# Tests de CHEMIN D'ERREUR : les controleurs loggent VOLONTAIREMENT l'echec (ex:
# Watchtower injoignable -> 502, Brain down -> 502) AVANT de renvoyer le bon statut
# HTTP teste. L'exception est attrapee, jamais propagee : ce sont des logs attendus.
logging.level.com.loremind.infrastructure.web.controller=OFF
# WARN attendus sur des tests de robustesse (registry injoignable, suppression best-effort).
logging.level.com.loremind.infrastructure.updates.UpdateCheckService=ERROR
logging.level.com.loremind.infrastructure.ai.BrainNotebookIndexClient=ERROR
# Le warning MinIO au @PostConstruct (serveur absent en test) est ATTENDU.
logging.level.com.loremind.infrastructure.storage=ERROR

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -57,6 +57,7 @@ $BrainDir = Join-Path $RepoRoot 'brain'
$CoreDir = Join-Path $RepoRoot 'core' $CoreDir = Join-Path $RepoRoot 'core'
$StageDir = Join-Path $CoreDir 'target\dist-input' # charge utile jpackage $StageDir = Join-Path $CoreDir 'target\dist-input' # charge utile jpackage
$OutDir = Join-Path $CoreDir 'target\dist-out' # .msi produit $OutDir = Join-Path $CoreDir 'target\dist-out' # .msi produit
$IconFile = Join-Path $PSScriptRoot 'app-icon.ico' # icone de l'app (.msi + raccourcis)
# --- Version (numerique pour MSI) ------------------------------------------ # --- Version (numerique pour MSI) ------------------------------------------
if (-not $Version) { if (-not $Version) {
@@ -213,11 +214,21 @@ $brainCmd = '$APPDIR\brain\python\python.exe,$APPDIR\brain\run_local.py'
# #
# Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent # Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent
# etre rediriges vers un fichier via une option ulterieure si besoin de debug. # etre rediriges vers un fichier via une option ulterieure si besoin de debug.
# Icone de l'application (.msi, raccourcis bureau/menu). Doit etre un .ico Windows
# multi-resolution (16/32/48/256). Absente -> jpackage retomberait sur l'icone Java
# par defaut : on echoue tot avec un message clair plutot que de livrer ca.
if (-not (Test-Path $IconFile)) {
Write-Err "Icone manquante : $IconFile"
Write-Err "Depose l'icone de l'app (image DM, .ico multi-resolution) a cet emplacement."
exit 1
}
jpackage ` jpackage `
--type msi ` --type msi `
--name LoreMind ` --name 'DM Loremind' `
--app-version $Version ` --app-version $Version `
--vendor 'IGML Creation' ` --vendor 'IGML Creation' `
--icon $IconFile `
--input $StageDir ` --input $StageDir `
--main-jar loremind-core.jar ` --main-jar loremind-core.jar `
--main-class org.springframework.boot.loader.launch.JarLauncher ` --main-class org.springframework.boot.loader.launch.JarLauncher `
@@ -226,7 +237,7 @@ jpackage `
--java-options "-Dbrain.sidecar.command=$brainCmd" ` --java-options "-Dbrain.sidecar.command=$brainCmd" `
--win-per-user-install ` --win-per-user-install `
--win-upgrade-uuid 'a7c4e1d2-9b3f-4e6a-8d05-1f2c3b4a5e6d' ` --win-upgrade-uuid 'a7c4e1d2-9b3f-4e6a-8d05-1f2c3b4a5e6d' `
--win-menu --win-menu-group 'LoreMind' ` --win-menu --win-menu-group 'DM Loremind' `
--win-shortcut --win-dir-chooser --win-shortcut --win-dir-chooser
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {

View File

@@ -52,7 +52,8 @@
"development": { "development": {
"optimization": false, "optimization": false,
"sourceMap": true, "sourceMap": true,
"namedChunks": true "namedChunks": true,
"outputHashing": "none"
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"

View File

@@ -35,7 +35,7 @@ test.describe('NPC edit', () => {
await page.getByLabel(/Nom du PNJ/i).fill(newName); await page.getByLabel(/Nom du PNJ/i).fill(newName);
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
// Retour à la campagne après save // Retour à la campagne après save
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`)); await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
@@ -48,7 +48,7 @@ test.describe('NPC edit', () => {
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`); await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
const nameField = page.getByLabel(/Nom du PNJ/i); const nameField = page.getByLabel(/Nom du PNJ/i);
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i }); const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled(); await expect(saveBtn).toBeEnabled();
await nameField.fill(''); await nameField.fill('');

View File

@@ -42,7 +42,7 @@ test.describe('GameSystem edit', () => {
await page.getByLabel(/^Nom/i).fill(newName); await page.getByLabel(/^Nom/i).fill(newName);
await page.getByLabel(/Description courte/i).fill(newDescription); await page.getByLabel(/Description courte/i).fill(newDescription);
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
// Retour a la liste apres save. // Retour a la liste apres save.
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
@@ -57,7 +57,7 @@ test.describe('GameSystem edit', () => {
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name); await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const nameField = page.getByLabel(/^Nom/i); const nameField = page.getByLabel(/^Nom/i);
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i }); const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled(); await expect(saveBtn).toBeEnabled();
await nameField.fill(''); await nameField.fill('');

View File

@@ -38,7 +38,7 @@ test.describe('GameSystem rule sections editor', () => {
await card.locator('.section-content').fill(sectionContent); await card.locator('.section-content').fill(sectionContent);
// Save + retour a la liste. // Save + retour a la liste.
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
// Verification cote API : le markdown contient bien la section + son contenu. // Verification cote API : le markdown contient bien la section + son contenu.

View File

@@ -44,7 +44,7 @@ test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
await expect(row.locator('.tfe-name')).toHaveValue('Histoire'); await expect(row.locator('.tfe-name')).toHaveValue('Histoire');
// Save → retour a la liste. // Save → retour a la liste.
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
// Verification API : le champ est bien dans characterTemplate. // Verification API : le champ est bien dans characterTemplate.
@@ -88,7 +88,7 @@ test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire'); await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire');
await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation'); await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation');
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json()); const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.16.2", "version": "0.17.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.16.2", "version": "0.17.0",
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.16.2", "version": "0.17.0",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -17,6 +17,12 @@ export default defineConfig({
reporter: process.env['CI'] ? [['html', { open: 'never' }], ['list']] : 'html', reporter: process.env['CI'] ? [['html', { open: 'never' }], ['list']] : 'html',
use: { use: {
baseURL, baseURL,
// Locale FR épinglée : l'app résout sa langue via celle du navigateur
// (LanguageService.resolveInitialLang → getBrowserLang()). Sans ça, le
// Chromium de Playwright démarre en en-US → l'UI passe en anglais → tous les
// tests, écrits pour les libellés français, échouent (vert sur une machine en
// locale FR, rouge en CI Linux en-US). On fixe donc le français, déterministe.
locale: 'fr-FR',
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
video: 'retain-on-failure', video: 'retain-on-failure',

View File

@@ -108,7 +108,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (row of tableValues[field.name] ?? []; track $index; let ri = $index) { @for (row of tableRows(field.name); track $index; let ri = $index) {
<tr> <tr>
@for (col of field.labels; track $index) { @for (col of field.labels; track $index) {
<td> <td>

View File

@@ -240,6 +240,11 @@ export class PageEditComponent implements OnInit, OnDestroy {
this.tableValues[fieldName]?.splice(rowIndex, 1); this.tableValues[fieldName]?.splice(rowIndex, 1);
} }
/** Lignes du tableau d'un champ — toujours un tableau (jamais undefined) pour le `@for`. */
tableRows(fieldName: string): Array<Record<string, string>> {
return this.tableValues[fieldName] ?? [];
}
// --- Chat IA conversationnel (Phase b5) -------------------------------- // --- Chat IA conversationnel (Phase b5) --------------------------------
toggleChat(): void { toggleChat(): void {

View File

@@ -1,28 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ProgressionStatus } from './campaign.model';
/**
* Endpoints de progression des quêtes pour un Playthrough.
* Modèle "absence = NOT_STARTED" — envoyer NOT_STARTED supprime la ligne côté backend.
*/
@Injectable({ providedIn: 'root' })
export class QuestProgressionService {
constructor(private http: HttpClient) {}
/** Map chapterId -> ProgressionStatus pour le Playthrough donné. */
list(playthroughId: string): Observable<Record<string, ProgressionStatus>> {
return this.http.get<Record<string, ProgressionStatus>>(
`/api/playthroughs/${playthroughId}/quest-progressions`
);
}
setStatus(playthroughId: string, chapterId: string, status: ProgressionStatus): Observable<void> {
return this.http.put<void>(
`/api/playthroughs/${playthroughId}/quest-progressions/${chapterId}`,
{ status }
);
}
}

View File

@@ -1,4 +1,4 @@
@if (fields?.length) { @if (fields.length) {
<div class="dff"> <div class="dff">
@for (f of fields; track trackByName($index, f)) { @for (f of fields; track trackByName($index, f)) {
<div class="dff-field"> <div class="dff-field">

View File

@@ -1,6 +0,0 @@
<span [class]="cssClass" [attr.aria-label]="label" [title]="label">
<lucide-icon [img]="icon" [size]="14"></lucide-icon>
@if (!compact) {
<span class="status-label">{{ label }}</span>
}
</span>

View File

@@ -1,43 +0,0 @@
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
border: 1px solid transparent;
}
.status-label {
white-space: nowrap;
}
// Verrouillée — gris discret
.status-locked {
background: rgba(128, 128, 128, 0.12);
color: #6b6b6b;
border-color: rgba(128, 128, 128, 0.25);
}
// Disponible — vert calme (prête à être lancée)
.status-available {
background: rgba(52, 168, 83, 0.12);
color: #2f7a47;
border-color: rgba(52, 168, 83, 0.3);
}
// En cours — bleu actif
.status-in_progress {
background: rgba(66, 133, 244, 0.14);
color: #2c6cd6;
border-color: rgba(66, 133, 244, 0.35);
}
// Terminée — violet doux (clos, pas neutre)
.status-completed {
background: rgba(120, 80, 200, 0.12);
color: #6d4fb5;
border-color: rgba(120, 80, 200, 0.3);
}

View File

@@ -1,48 +0,0 @@
import { Component, Input } from '@angular/core';
import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular';
import { TranslateService } from '@ngx-translate/core';
import { QuestStatus } from '../../services/campaign.model';
/**
* Badge visuel pour un QuestStatus (vue Hub).
* Composant standalone, sans dépendance métier.
*/
@Component({
selector: 'app-quest-status-badge',
imports: [LucideAngularModule],
templateUrl: './quest-status-badge.component.html',
styleUrls: ['./quest-status-badge.component.scss']
})
export class QuestStatusBadgeComponent {
@Input() status: QuestStatus | undefined | null = 'AVAILABLE';
/** Variante visuelle compacte (sans label) — utile pour les listes denses. */
@Input() compact = false;
constructor(private translate: TranslateService) {}
get icon(): LucideIconData {
switch (this.status) {
case 'LOCKED': return Lock;
case 'IN_PROGRESS': return Play;
case 'COMPLETED': return CheckCircle2;
case 'AVAILABLE':
default: return Circle;
}
}
get label(): string {
switch (this.status) {
case 'LOCKED': return this.translate.instant('questStatusBadge.locked');
case 'IN_PROGRESS': return this.translate.instant('questStatusBadge.inProgress');
case 'COMPLETED': return this.translate.instant('questStatusBadge.completed');
case 'AVAILABLE':
default: return this.translate.instant('questStatusBadge.available');
}
}
get cssClass(): string {
return `status-badge status-${(this.status ?? 'AVAILABLE').toLowerCase()}`;
}
}

View File

@@ -2,8 +2,8 @@
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
<span class="logo-icon"></span> <img class="logo-img" src="assets/logo.png" alt="DM Loremind" />
<span class="logo-text">LoreMind</span> <span class="logo-text">DM Loremind</span>
</div> </div>
<p class="logo-subtitle">{{ 'sidebar.subtitle' | translate }}</p> <p class="logo-subtitle">{{ 'sidebar.subtitle' | translate }}</p>
</div> </div>

View File

@@ -18,6 +18,7 @@
} }
.logo-icon { font-size: 1.2rem; color: #6c63ff; } .logo-icon { font-size: 1.2rem; color: #6c63ff; }
.logo-img { width: 1.75rem; height: 1.75rem; border-radius: 6px; object-fit: contain; flex: 0 0 auto; }
.logo-text { font-size: 1.25rem; font-weight: 700; color: white; } .logo-text { font-size: 1.25rem; font-weight: 700; color: white; }
.logo-subtitle { .logo-subtitle {

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
web/src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -2,7 +2,7 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>LoreMind</title> <title>DM Loremind</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">