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:
1 parent
d080da0ee3
commit
923ddd387a
18 files changed
+3391
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+1491
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user