feat: squelettes Studio et prototype d'échange GPU Vulkan

Studio : Vite + TypeScript strict sans framework, état sérialisable et réduction pure, connexion au Core simulée et marquée comme telle, tests vitest. Prototype jetable gpu_interop étape 1 : export/import mémoire Vulkan OPAQUE_WIN32 entre deux VkDevice, vérification octet à octet et mesures.
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-06 19:32:11 +01:00
1 parent d080da0ee3
commit 923ddd387a
18 files changed
+3391

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# SPDX-License-Identifier: MPL-2.0
#
# Prototypes jetables (non produit). Voir docs/SEQUA_Engine_resume_complet_v2.md §5.6.
# Seuls les prototypes supportes par la plateforme courante sont ajoutes.
if(WIN32)
add_subdirectory(gpu_interop)
else()
message(STATUS "prototypes : gpu_interop ignore (Windows uniquement pour l'instant)")
endif()
+18
View File
@@ -0,0 +1,18 @@
# SPDX-License-Identifier: MPL-2.0
#
# Prototype jetable : export/import d'une texture Vulkan via un HANDLE Win32.
# Aucune dependance hors Vulkan SDK.
find_package(Vulkan REQUIRED)
add_executable(gpu_interop_vk_export
main.cpp)
target_link_libraries(gpu_interop_vk_export PRIVATE Vulkan::Vulkan)
sequa_target_defaults(gpu_interop_vk_export)
if(SEQUA_BUILD_TESTS)
add_test(NAME gpu_interop_vk_export COMMAND gpu_interop_vk_export)
set_tests_properties(gpu_interop_vk_export PROPERTIES LABELS "gpu;prototype")
endif()
+154
View File
@@ -0,0 +1,154 @@
> **PROTOTYPE JETABLE — pas du code produit.**
> Ce dossier sert uniquement à valider une hypothèse technique. Il n'a ni les garanties, ni l'API,
> ni la qualité d'ingénierie attendues du reste du dépôt, et il sera supprimé ou réécrit.
# gpu_interop — export/import d'une texture Vulkan (étape 1)
## SEQUA Engine en bref
**SEQUA** signifie **Simulate Environments, Quantify Uncertainty, Adapt** : l'engine simule des
environnements, quantifie l'incertitude de leur représentation et adapte les méthodes de calcul et de
rendu au contexte. Le produit s'articule en quatre couches : **SEQUA Studio** (l'outil de création),
**SEQUA Core** (format de projet, simulation déterministe, orchestration), **SEQUA 3D** (le contrat de
données et les adaptateurs) et des **moteurs 3D interchangeables** (Godot, Unreal, puis un moteur
maison) qui exécutent le rendu sans contenir la logique du produit. Le rendu neuronal et génératif
est un mode optionnel, piloté par des budgets et des cartes de confiance.
## Rôle de ce prototype dans l'engine
Le risque n°1 identifié au §5.6 de `docs/SEQUA_Engine_resume_complet_v2.md` est la **fuite des
abstractions au passage GPU rendu → inférence** :
> « Les données restent résidentes sur le GPU autant que possible. Le passage de textures entre l'API
> graphique (Vulkan, DirectX) et le moteur d'inférence (CUDA, TensorRT) sans copie CPU est le point où
> les abstractions fuient le plus ; il est testé en tout premier. »
Ce prototype vérifie donc la brique la plus basse et la plus décisive : une texture produite par un
contexte Vulkan peut-elle être **exportée puis réutilisée par un autre contexte GPU** sans jamais
transiter par la mémoire système ? Si l'étape 1 échoue, le contrat `NeuralFrameContext` (§5.6) doit
prévoir des copies CPU, ce qui change l'architecture et les budgets. L'étape 1 ne dépend que du
Vulkan SDK et ne touche ni à CUDA ni à ONNX Runtime.
## Étapes prévues
| Étape | Contenu | État |
|---|---|---|
| 1 | Export d'une `VkImage` en `HANDLE` Win32 (`OPAQUE_WIN32`), import dans un second `VkDevice`, comparaison octet à octet, mesures | **fait (ce programme)** |
| 2 | Import de la même allocation dans CUDA via `cudaImportExternalMemory` / `cudaExternalMemoryGetMappedMipmappedArray` | à faire — **nécessite le CUDA Toolkit, non installé sur la machine de référence** |
| 3 | Inférence ONNX Runtime (ou TensorRT) directement sur le tampon importé, sans copie CPU | à faire |
| 4 | Synchronisation fine par sémaphores externes (`VK_KHR_external_semaphore_win32` / `cudaExternalSemaphore`), suppression des `vkQueueWaitIdle` | à faire — l'extension est **disponible** sur le GPU de test, vérifiée et affichée par l'étape 1 |
## Ce que fait le programme
1. Crée une instance Vulkan headless (aucune fenêtre, aucune surface) sur API 1.2.
2. Sélectionne un GPU avec file graphique et `VK_KHR_external_memory_win32`, puis crée **deux
`VkDevice` distincts sur le même `VkPhysicalDevice`**, et vérifie que leurs `deviceUUID` concordent.
3. Alloue une `VkImage` **1920×1080 `VK_FORMAT_R8G8B8A8_UNORM`** en mémoire `DEVICE_LOCAL`, avec
`VkExternalMemoryImageCreateInfo` + `VkExportMemoryAllocateInfo`
(`VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT`) et une allocation dédiée.
4. La remplit avec un motif déterministe (R = x & 0xFF, G = y & 0xFF, B = (x ^ y) & 0xFF, A = 0xFF)
par staging buffer + `vkCmdCopyBufferToImage` — **aucun shader**, donc aucune dépendance glslang.
5. Exporte la mémoire avec `vkGetMemoryWin32HandleKHR`, importe le `HANDLE` dans le second device
(`VkImportMemoryWin32HandleInfoKHR`), lie une `VkImage` identique à la mémoire importée.
6. Relit l'image du second device via un staging buffer et compare **octet à octet** au motif.
7. Mesure l'export, l'import et, à titre de référence, un aller-retour **GPU → CPU → GPU** complet
de la même image (5 essais, minimum et médiane).
8. Ferme le `HANDLE` avec `CloseHandle` et libère toutes les ressources (RAII).
Code de retour : `0` si les 8 294 400 octets sont identiques, `2` si le contenu diffère, `1` sur
erreur Vulkan ou d'initialisation.
## Construire et lancer
Prérequis : Windows 10/11, Visual Studio 2022 avec les outils C++ x64, Vulkan SDK (testé avec
1.4.341.1), un GPU exposant `VK_KHR_external_memory_win32`.
Depuis la racine du dépôt :
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\build.ps1 -Preset windows-msvc-prototypes
```
Le script configure, compile et lance `ctest`. Pour exécuter seulement l'essai du prototype :
```powershell
ctest --preset windows-msvc-prototypes -L prototype --output-on-failure
```
Ou directement :
```powershell
.\build\windows-msvc-prototypes\prototypes\gpu_interop\gpu_interop_vk_export.exe
```
Sous Linux/macOS, `prototypes/CMakeLists.txt` n'ajoute pas la cible et affiche un message `STATUS` :
le code de cette étape est Windows uniquement.
## Résultats mesurés
Machine de référence : Windows 11, NVIDIA GeForce RTX 4090 Laptop GPU (16 Go), pilote Vulkan
1.4.357, MSVC 14.44, build `RelWithDebInfo`, 6 novembre 2026.
Image : 1920×1080 `R8G8B8A8_UNORM`, 8 294 400 octets utiles, allocation de 8 847 360 octets.
| Mesure | Valeur |
|---|---|
| Export `vkGetMemoryWin32HandleKHR` | **0,001 ms** |
| Import dans le second `VkDevice` (création image + `vkAllocateMemory` + `vkBindImageMemory`) | **0,066 – 0,135 ms** (selon l'exécution) |
| Aller-retour GPU → CPU → GPU de référence (médiane de 5 essais) | **30,7 – 37,0 ms** |
| Comparaison octet à octet après import | **identique** (8 294 400 / 8 294 400 octets) |
Extrait d'exécution :
```text
[5] Export : HANDLE Win32 0x00000000000005F4 en 0.001 ms
[6] Import dans le device B : type 1, 0.075 ms (creation image + alloc + bind)
[7] Comparaison octet a octet apres import : IDENTIQUE
[8] CloseHandle du HANDLE exporte : OK
[9] Aller-retour GPU->CPU->GPU de reference (5 essais)
min 30.612 ms, mediane 30.725 ms, contenu identique
=== RESULTATS ===
Export HANDLE Win32 : 0.001 ms
Import dans un 2e device : 0.075 ms
Aller-retour GPU->CPU->GPU : 30.725 ms (mediane de 5 essais)
Verification octet a octet : OK
```
Lecture : l'export et l'import sont des opérations de métadonnées (aucune donnée déplacée), d'où des
temps négligeables devant le round-trip CPU, qui déplace réellement 2 × 8,3 Mo sur le bus et subit
deux synchronisations `vkQueueWaitIdle` (latence WDDM). C'est exactement l'écart que l'étape 2 doit
rendre inutile.
## Limites et points non vérifiés
- Import d'un handle `OPAQUE_WIN32` : `vkGetMemoryWin32HandlePropertiesKHR` ne doit **pas** être
appelé pour ce type de handle (VUID-vkGetMemoryWin32HandlePropertiesKHR-handleType-00666 ; la
première version l'appelait et obtenait `VK_ERROR_INITIALIZATION_FAILED`). La spec impose
d'importer avec le même `memoryTypeIndex` et la même taille qu'à l'export ; le programme vérifie
seulement que l'image importée accepte ce type. Non retesté avec les validation layers.
- La synchronisation inter-devices repose sur un `vkQueueWaitIdle` avant l'export : suffisant pour un
prototype mono-thread, **pas** un mécanisme de production. L'étape 4 doit le remplacer par des
sémaphores externes.
- Les deux `VkDevice` vivent dans le **même processus**. Un partage inter-processus demanderait
`DuplicateHandle`.
- La vérification `deviceUUID` compare deux requêtes sur le même `VkPhysicalDevice` : elle est donc
structurellement vraie ici. Elle deviendra significative quand le device d'inférence sera créé par
un autre runtime (CUDA) ou un autre processus.
- L'aller-retour de référence inclut deux soumissions, deux attentes de file et les copies hôte ;
il mesure donc surtout la latence de synchronisation Windows, pas la bande passante PCIe.
- Les temps export/import sont des mesures uniques par exécution (ils ne peuvent pas être répétés sans
réexporter) ; seule la variabilité de l'aller-retour est établie sur 5 essais.
- Aucune couche de validation Vulkan n'est activée : le prototype n'a pas été passé sous
`VK_LAYER_KHRONOS_validation`.
- Étape 2 non commencée : aucun CUDA Toolkit n'est installé, aucune mesure CUDA n'est donc disponible.
## Licences
- Ce prototype est publié sous **MPL 2.0**, comme le reste de SEQUA (voir `LICENSE` à la racine).
Chaque fichier source porte l'en-tête `SPDX-License-Identifier: MPL-2.0`.
- Dépendances de l'étape 1 : **Vulkan SDK / Vulkan-Loader** (Apache 2.0) uniquement. Aucune autre
bibliothèque n'est liée : pas de glslang, pas de validation layer, pas de framework de test dédié.
- **CUDA et TensorRT sont propriétaires (NVIDIA)**. Ils n'entreront jamais dans le cœur : ce seront
des plugins facultatifs derrière `IComputeBackend`, conformément au §10.2 du document d'architecture,
et ils ne seront pas requis pour compiler ou exécuter l'engine.
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
# SEQUA Studio
**SEQUA** signifie *Simulate Environments, Quantify Uncertainty, Adapt* : simuler des environnements, quantifier l'incertitude liée à leur représentation et aux traitements, puis adapter les méthodes de calcul et de rendu au contexte. SEQUA Engine réunit quatre couches : **Studio** (la création), **Core** (la source de vérité : projet, simulation déterministe, rejeu, intentions, orchestrateur de rendu, confiance), **SEQUA 3D** (le contrat de données et la synchronisation vers les moteurs) et des **moteurs 3D interchangeables** (Godot, puis Unreal, à terme un moteur maison). SEQUA possède le projet et pilote les moteurs ; il n'est pas un plugin d'un autre moteur.
## Rôle de Studio
Studio est le **cœur et le visage du produit** : c'est là que l'auteur exprime son intention, règle le ressenti, compose les modes de jeu et compare des variantes. Studio est une application web TypeScript indépendante de tout moteur ; il ne parle au Core que par un **protocole** (WebSocket et schéma partagé, Protobuf ou FlatBuffers — format non encore choisi). Son conteneur (navigateur, webview système, CEF) est un détail remplaçable.
L'interface est une **vue d'un modèle d'état sérialisable** : elle ne porte aucune logique produit. Les interactions produisent des événements, l'état évolue uniquement par une fonction pure de réduction, puis la vue est réaffichée.
## État actuel
Squelette uniquement, sans framework UI ni bibliothèque de composants :
- projet Vite + TypeScript strict, tests Vitest ;
- état minimal (`connection`, `protocolVersion`, liste d'intentions vide) et réduction pure ;
- interface sobre affichant le titre, le statut de connexion et la mention **« Core simulé »** ;
- connexion **simulée** : `SimulatedCoreConnection` n'ouvre aucune socket et reproduit la séquence `connecting` → `connected`. Tout ce qui est simulé est marqué comme tel dans l'interface ;
- pas encore de protocole WebSocket réel ni de schéma partagé.
## Commandes
Prérequis : Node.js 24 recommandé (Vitest 5 accepte `^22.12`, `^24` et `>=26` ; Vite 8 accepte `^20.19` ou `>=22.12`).
```bash
npm ci # installation reproductible depuis package-lock.json
npm run dev # serveur de développement Vite
npm run check # typecheck, tests, puis build de production
```
Autres scripts : `npm run typecheck` (`tsc --noEmit`), `npm run test` (`vitest run`), `npm run build` (`vite build`), `npm run preview`.
## Structure
```text
studio/
├── index.html
├── package.json
├── tsconfig.json
├── src/
│ ├── main.ts # montage dans #app, câblage connexion ↔ vue
│ ├── style.css # thème sombre minimal
│ ├── model/state.ts # StudioState, événements, état initial, reduce pure
│ ├── protocol/connection.ts # interface CoreConnection + connexion SIMULÉE
│ └── ui/app.ts # rendu de l'état et actions de connexion
└── tests/
├── state.test.ts
└── connection.test.ts
```
## Licences
**SEQUA Studio est distribué sous Mozilla Public License 2.0 (MPL-2.0).** Chaque fichier source porte son identifiant SPDX.
Dépendances directes de développement (versions et licences relevées dans `node_modules/<paquet>/package.json` après installation) :
| Paquet | Version | Licence |
|---|---|---|
| vite | 8.3.6 | MIT |
| typescript | 7.0.2 | Apache-2.0 |
| vitest | 5.1.0 | MIT |
Ce sont les seules dépendances directes. Les paquets transitifs nécessaires à Vite, TypeScript et Vitest sont verrouillés par `package-lock.json` et restent des dépendances de développement, absentes du build de production en dehors des fichiers générés par Vite.
+14
View File
@@ -0,0 +1,14 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>SEQUA Studio</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1491
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@sequa/studio",
"version": "0.0.0",
"private": true,
"license": "MPL-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"check": "npm run typecheck && npm run test && npm run build"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.6",
"vitest": "5.1.0"
}
}
View File
Whitespace-only changes.
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MPL-2.0
import './style.css';
import { createInitialState } from './model/state';
import { SimulatedCoreConnection } from './protocol/connection';
import { mountApp } from './ui/app';
const root = document.querySelector<HTMLElement>('#app');
if (root === null) {
throw new Error('Élément #app introuvable dans index.html');
}
// SIMULÉ : aucune connexion réseau n'est établie à ce stade.
const connection = new SimulatedCoreConnection();
const app = mountApp({
root,
initialState: createInitialState(),
coreLabel: connection.label,
actions: {
connect: () => {
connection.connect();
},
disconnect: () => {
connection.disconnect();
},
},
});
connection.onEvent((event) => {
app.dispatch(event);
});
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MPL-2.0
/** Version du protocole Studio — Core. Aucun protocole réel n'est encore implémenté. */
export const PROTOCOL_VERSION = '0.1.0';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
/** Intention de création. La liste est volontairement vide dans le squelette. */
export interface Intention {
readonly id: string;
readonly label: string;
}
/**
* Modèle d'état de Studio.
*
* Il est sérialisable (JSON) et ne contient aucune logique produit : l'interface
* n'en est qu'une vue.
*/
export interface StudioState {
readonly connection: ConnectionStatus;
readonly protocolVersion: string;
readonly intentions: readonly Intention[];
}
/** Événements de connexion au Core, seuls événements du squelette. */
export type StudioEvent =
| { readonly type: 'core/connecting' }
| { readonly type: 'core/connected' }
| { readonly type: 'core/disconnected' };
/** État initial : rien n'est connecté, aucune intention n'existe encore. */
export function createInitialState(): StudioState {
return {
connection: 'disconnected',
protocolVersion: PROTOCOL_VERSION,
intentions: [],
};
}
/**
* Réduction pure : produit un nouvel état sans jamais modifier l'état reçu.
* L'interface ne change d'état que par cette fonction.
*/
export function reduce(state: StudioState, event: StudioEvent): StudioState {
switch (event.type) {
case 'core/connecting':
return { ...state, connection: 'connecting' };
case 'core/connected':
return { ...state, connection: 'connected' };
case 'core/disconnected':
return { ...state, connection: 'disconnected' };
}
const unreachable: never = event;
return unreachable;
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MPL-2.0
import type { ConnectionStatus, StudioEvent } from '../model/state';
export type CoreEventListener = (event: StudioEvent) => void;
/**
* Contrat de connexion au Core.
*
* Studio ne parle au Core que par protocole (WebSocket plus schéma partagé).
* Toute implémentation réelle devra respecter cette interface.
*/
export interface CoreConnection {
/** Libellé affiché par l'interface. */
readonly label: string;
/** Vrai lorsque l'implémentation ne contacte aucun Core réel. */
readonly simulated: boolean;
connect(): void;
disconnect(): void;
/** S'abonne aux événements ; retourne la fonction de désabonnement. */
onEvent(listener: CoreEventListener): () => void;
}
const STATUS_EVENTS: Record<ConnectionStatus, StudioEvent> = {
disconnected: { type: 'core/disconnected' },
connecting: { type: 'core/connecting' },
connected: { type: 'core/connected' },
};
/**
* SIMULÉ — cette implémentation ne contacte aucun Core.
*
* Elle reproduit de façon synchrone la séquence d'événements d'une vraie
* connexion (connecting, puis connected) afin de développer l'interface avant
* l'arrivée du protocole. Elle doit rester clairement identifiée comme
* simulée dans l'interface et ne doit jamais être présentée comme un Core réel.
*/
export class SimulatedCoreConnection implements CoreConnection {
public readonly label = 'Core simulé';
public readonly simulated = true;
private readonly listeners = new Set<CoreEventListener>();
private status: ConnectionStatus = 'disconnected';
public connect(): void {
if (this.status !== 'disconnected') {
return;
}
this.transition('connecting');
this.transition('connected');
}
public disconnect(): void {
if (this.status === 'disconnected') {
return;
}
this.transition('disconnected');
}
public onEvent(listener: CoreEventListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private transition(next: ConnectionStatus): void {
this.status = next;
const event = STATUS_EVENTS[next];
for (const listener of this.listeners) {
listener(event);
}
}
}
+172
View File
@@ -0,0 +1,172 @@
/* SPDX-License-Identifier: MPL-2.0 */
:root {
color-scheme: dark;
--bg: #0c0f13;
--panel: #141a21;
--panel-raised: #1a222b;
--border: #27313c;
--text: #e7edf4;
--muted: #94a3b3;
--accent: #62b6ff;
--ok: #57d38c;
--warn: #e8b45c;
--off: #778592;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 50% 0%, rgb(98 182 255 / 8%), transparent 55%),
var(--bg);
color: var(--text);
}
#app {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.studio {
width: min(42rem, 100%);
padding: 2rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 35%);
}
.studio__header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.studio__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.badge {
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.badge--simulated {
color: var(--warn);
background: rgb(232 180 92 / 12%);
border: 1px solid rgb(232 180 92 / 35%);
}
.status {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 1.25rem 0 0.5rem;
font-size: 1rem;
}
.status__dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--off);
flex: none;
}
.status[data-status="connecting"] .status__dot {
background: var(--warn);
}
.status[data-status="connected"] .status__dot {
background: var(--ok);
}
.note {
margin: 0 0 1.5rem;
color: var(--muted);
font-size: 0.85rem;
}
.facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1.5rem;
margin: 0 0 1.75rem;
padding: 1rem 1.25rem;
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.facts dt {
color: var(--muted);
font-size: 0.85rem;
}
.facts dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.controls {
display: flex;
gap: 0.75rem;
}
.button {
padding: 0.5rem 1.1rem;
border: 1px solid transparent;
border-radius: 0.4rem;
background: var(--accent);
color: #06121f;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.button:hover:not(:disabled) {
filter: brightness(1.1);
}
.button--ghost {
background: transparent;
color: var(--text);
border-color: var(--border);
}
.button--ghost:hover:not(:disabled) {
border-color: var(--muted);
}
.button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
+124
View File
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: MPL-2.0
import type { ConnectionStatus, StudioEvent, StudioState } from '../model/state';
import { reduce } from '../model/state';
export interface AppActions {
readonly connect: () => void;
readonly disconnect: () => void;
}
export interface AppOptions {
readonly root: HTMLElement;
readonly initialState: StudioState;
/** Libellé de la connexion ; « Core simulé » pour l'implémentation factice. */
readonly coreLabel: string;
readonly actions: AppActions;
}
export interface App {
getState(): StudioState;
/** Seul chemin de modification de l'état : délègue à `reduce`. */
dispatch(event: StudioEvent): void;
}
const STATUS_LABELS: Record<ConnectionStatus, string> = {
disconnected: 'Déconnecté',
connecting: 'Connexion…',
connected: 'Connecté',
};
function element<K extends keyof HTMLElementTagNameMap>(
tag: K,
className?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className !== undefined) {
node.className = className;
}
if (text !== undefined) {
node.textContent = text;
}
return node;
}
/**
* Monte la vue de Studio dans `root`.
*
* La vue lit l'état et ne le modifie jamais directement : les interactions
* passent par les actions (connexion) puis par `dispatch`, donc par `reduce`.
*/
export function mountApp(options: AppOptions): App {
const { root, initialState, coreLabel, actions } = options;
let state = initialState;
root.replaceChildren();
const shell = element('main', 'studio');
const header = element('header', 'studio__header');
const title = element('h1', 'studio__title', 'SEQUA Studio');
const simulationBadge = element('span', 'badge badge--simulated', coreLabel);
simulationBadge.title = 'SIMULÉ — aucun Core réel n’est contacté.';
header.append(title, simulationBadge);
const statusLine = element('p', 'status');
statusLine.setAttribute('role', 'status');
statusLine.setAttribute('aria-live', 'polite');
const statusDot = element('span', 'status__dot');
statusDot.setAttribute('aria-hidden', 'true');
const statusText = element('span', 'status__text');
statusLine.append(statusDot, statusText);
const simulationNote = element(
'p',
'note',
'Connexion simulée : l’interface est développée sans Core réel, en attendant le protocole WebSocket.',
);
const details = element('dl', 'facts');
const versionTerm = element('dt', undefined, 'Version du protocole');
const versionValue = element('dd', 'facts__value');
const intentionsTerm = element('dt', undefined, 'Intentions');
const intentionsValue = element('dd', 'facts__value');
details.append(versionTerm, versionValue, intentionsTerm, intentionsValue);
const controls = element('div', 'controls');
const connectButton = element('button', 'button', 'Connecter');
connectButton.type = 'button';
connectButton.addEventListener('click', () => {
actions.connect();
});
const disconnectButton = element('button', 'button button--ghost', 'Déconnecter');
disconnectButton.type = 'button';
disconnectButton.addEventListener('click', () => {
actions.disconnect();
});
controls.append(connectButton, disconnectButton);
shell.append(header, statusLine, simulationNote, details, controls);
root.append(shell);
const render = (): void => {
statusLine.dataset['status'] = state.connection;
statusText.textContent = STATUS_LABELS[state.connection];
versionValue.textContent = state.protocolVersion;
intentionsValue.textContent = String(state.intentions.length);
connectButton.disabled = state.connection !== 'disconnected';
disconnectButton.disabled = state.connection === 'disconnected';
};
render();
return {
getState: () => state,
dispatch: (event) => {
state = reduce(state, event);
render();
},
};
}
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MPL-2.0
/// <reference types="vite/client" />
+63
View File
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: MPL-2.0
import { describe, expect, it, vi } from 'vitest';
import { SimulatedCoreConnection } from '../src/protocol/connection';
describe('SimulatedCoreConnection', () => {
it('se déclare clairement comme simulée', () => {
const connection = new SimulatedCoreConnection();
expect(connection.simulated).toBe(true);
expect(connection.label).toBe('Core simulé');
});
it('émet connecting puis connected à la connexion', () => {
const connection = new SimulatedCoreConnection();
const listener = vi.fn();
connection.onEvent(listener);
connection.connect();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenNthCalledWith(1, { type: 'core/connecting' });
expect(listener).toHaveBeenNthCalledWith(2, { type: 'core/connected' });
});
it('émet disconnected à la déconnexion', () => {
const connection = new SimulatedCoreConnection();
const listener = vi.fn();
connection.onEvent(listener);
connection.connect();
listener.mockClear();
connection.disconnect();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ type: 'core/disconnected' });
});
it('ignore les transitions redondantes', () => {
const connection = new SimulatedCoreConnection();
const listener = vi.fn();
connection.onEvent(listener);
connection.disconnect();
expect(listener).not.toHaveBeenCalled();
connection.connect();
listener.mockClear();
connection.connect();
expect(listener).not.toHaveBeenCalled();
});
it('cesse d’émettre après désabonnement', () => {
const connection = new SimulatedCoreConnection();
const listener = vi.fn();
const unsubscribe = connection.onEvent(listener);
unsubscribe();
connection.connect();
expect(listener).not.toHaveBeenCalled();
});
});
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MPL-2.0
import { describe, expect, it } from 'vitest';
import { PROTOCOL_VERSION, createInitialState, reduce } from '../src/model/state';
describe('état initial', () => {
it('est déconnecté, versionné et sans intention', () => {
const state = createInitialState();
expect(state.connection).toBe('disconnected');
expect(state.protocolVersion).toBe(PROTOCOL_VERSION);
expect(state.intentions).toEqual([]);
});
it('est sérialisable en JSON', () => {
const state = createInitialState();
expect(JSON.parse(JSON.stringify(state))).toEqual(state);
});
});
describe('reduce', () => {
it('passe par connecting puis connected', () => {
const initial = createInitialState();
const connecting = reduce(initial, { type: 'core/connecting' });
const connected = reduce(connecting, { type: 'core/connected' });
expect(connecting.connection).toBe('connecting');
expect(connected.connection).toBe('connected');
});
it('revient à disconnected', () => {
const connected = reduce(createInitialState(), { type: 'core/connected' });
const disconnected = reduce(connected, { type: 'core/disconnected' });
expect(disconnected.connection).toBe('disconnected');
});
it('est pure : l’état reçu reste inchangé', () => {
const initial = createInitialState();
const next = reduce(initial, { type: 'core/connected' });
expect(next).not.toBe(initial);
expect(initial.connection).toBe('disconnected');
expect(next.protocolVersion).toBe(initial.protocolVersion);
expect(next.intentions).toBe(initial.intentions);
});
});
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src", "tests"]
}