Compare commits
No commits in common. "__dolt_remote_info__" and "main" have entirely different histories.
__dolt_rem
...
main
1038 changed files with 108110 additions and 7 deletions
80
.agents/skills/beads/SKILL.md
Normal file
80
.agents/skills/beads/SKILL.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
name: beads
|
||||||
|
description: Use when working in a repository that uses bd or Beads for durable project task tracking, issue dependencies, blocker management, multi-session handoff, or shared work memory. Trigger when the user asks to find ready work, claim or close tasks, create follow-up work, inspect blockers, recover project context, or choose between local planning and persistent project tracking.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Beads
|
||||||
|
|
||||||
|
Use Beads as the shared project task system. Local plans, scratch files, and personal memories are useful, but they are not the durable source of truth for project work.
|
||||||
|
|
||||||
|
## First Step
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd prime
|
||||||
|
```
|
||||||
|
|
||||||
|
If that prints nothing, check whether the repository has an active Beads workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd where
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preferred Route
|
||||||
|
|
||||||
|
Use the `bd` CLI when shell access is available. It is the most compact and direct Beads interface.
|
||||||
|
|
||||||
|
## Core CLI Workflow
|
||||||
|
|
||||||
|
1. Find work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready
|
||||||
|
bd list --status=open
|
||||||
|
bd list --status=in_progress
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Inspect before editing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd show <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Claim work atomically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd update <id> --claim
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create durable follow-up work when implementation reveals new tasks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd create "Short title" --description="Why this exists and what needs to be done" --type=task --priority=2
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Close completed work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd close <id> --reason="Completed"
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Belongs In Beads
|
||||||
|
|
||||||
|
Use Beads for:
|
||||||
|
|
||||||
|
- shared project tasks
|
||||||
|
- blockers and dependencies
|
||||||
|
- discovered follow-up work
|
||||||
|
- work that must survive thread reset, compaction, or handoff
|
||||||
|
- status that another person or agent should be able to resume
|
||||||
|
|
||||||
|
Use agent-local planning tools only for the current turn's execution checklist. Do not treat them as shared project state.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Do not create markdown TODO files as the source of truth when Beads is available.
|
||||||
|
- Do not use `bd edit`; it opens an interactive editor. Use `bd update` flags instead.
|
||||||
|
- Prefer `--json` when parsing `bd` output programmatically.
|
||||||
|
- If hooks are installed, `bd prime` may already be injected. Run it manually when context is missing.
|
||||||
|
- Do not auto-close or mutate tasks unless the work is actually complete.
|
||||||
4
.agents/skills/beads/agents/openai.yaml
Normal file
4
.agents/skills/beads/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
interface:
|
||||||
|
display_name: "Beads"
|
||||||
|
short_description: "Project task tracking with bd"
|
||||||
|
default_prompt: "Use $beads to inspect ready work and manage durable project tasks."
|
||||||
77
.beads/.gitignore
vendored
Normal file
77
.beads/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Dolt database (managed by Dolt, not git)
|
||||||
|
dolt/
|
||||||
|
embeddeddolt/
|
||||||
|
proxieddb/
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
bd.sock
|
||||||
|
bd.sock.startlock
|
||||||
|
sync-state.json
|
||||||
|
last-touched
|
||||||
|
.exclusive-lock
|
||||||
|
|
||||||
|
# Daemon runtime (lock, log, pid)
|
||||||
|
daemon.*
|
||||||
|
|
||||||
|
# Push state (runtime, per-machine)
|
||||||
|
push-state.json
|
||||||
|
|
||||||
|
# Lock files (various runtime locks)
|
||||||
|
*.lock
|
||||||
|
|
||||||
|
# Credential key (encryption key for federation peer auth — never commit)
|
||||||
|
.beads-credential-key
|
||||||
|
|
||||||
|
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||||
|
.local_version
|
||||||
|
|
||||||
|
proxied_server_client_info.json
|
||||||
|
|
||||||
|
# Worktree redirect file (contains relative path to main repo's .beads/)
|
||||||
|
# Must not be committed as paths would be wrong in other clones
|
||||||
|
redirect
|
||||||
|
|
||||||
|
# Sync state (local-only, per-machine)
|
||||||
|
# These files are machine-specific and should not be shared across clones
|
||||||
|
.sync.lock
|
||||||
|
export-state/
|
||||||
|
export-state.json
|
||||||
|
last_pull
|
||||||
|
|
||||||
|
# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned)
|
||||||
|
ephemeral.sqlite3
|
||||||
|
ephemeral.sqlite3-journal
|
||||||
|
ephemeral.sqlite3-wal
|
||||||
|
ephemeral.sqlite3-shm
|
||||||
|
|
||||||
|
# Dolt server management (auto-started by bd)
|
||||||
|
dolt-server.pid
|
||||||
|
dolt-server.log
|
||||||
|
dolt-server.lock
|
||||||
|
dolt-server.port
|
||||||
|
dolt-server.activity
|
||||||
|
|
||||||
|
# Debug-mode pprof artifacts (written when dolt.debug: true in config.yaml)
|
||||||
|
dolt-pprof/
|
||||||
|
|
||||||
|
# Corrupt backup directories (created by bd doctor --fix recovery)
|
||||||
|
*.corrupt.backup/
|
||||||
|
|
||||||
|
# Backup data (auto-exported JSONL, local-only)
|
||||||
|
backup/
|
||||||
|
|
||||||
|
# Per-project environment file (Dolt connection config, GH#2520)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Legacy files (from pre-Dolt versions)
|
||||||
|
*.db
|
||||||
|
*.db?*
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
db.sqlite
|
||||||
|
bd.db
|
||||||
|
# NOTE: Do NOT add negation patterns here.
|
||||||
|
# They would override fork protection in .git/info/exclude.
|
||||||
|
# Config files (metadata.json, config.yaml) are tracked by git by default
|
||||||
|
# since no pattern above ignores them.
|
||||||
81
.beads/README.md
Normal file
81
.beads/README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Beads - AI-Native Issue Tracking
|
||||||
|
|
||||||
|
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
|
||||||
|
|
||||||
|
## What is Beads?
|
||||||
|
|
||||||
|
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
|
||||||
|
|
||||||
|
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create new issues
|
||||||
|
bd create "Add user authentication"
|
||||||
|
|
||||||
|
# View all issues
|
||||||
|
bd list
|
||||||
|
|
||||||
|
# View issue details
|
||||||
|
bd show <issue-id>
|
||||||
|
|
||||||
|
# Update issue status
|
||||||
|
bd update <issue-id> --claim
|
||||||
|
bd update <issue-id> --status done
|
||||||
|
|
||||||
|
# Sync with Dolt remote
|
||||||
|
bd dolt push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with Issues
|
||||||
|
|
||||||
|
Issues in Beads are:
|
||||||
|
- **Git-native**: Stored in Dolt database with version control and branching
|
||||||
|
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
|
||||||
|
- **Branch-aware**: Issues can follow your branch workflow
|
||||||
|
- **Sync-ready**: Uses Dolt remotes for backup and team sharing
|
||||||
|
|
||||||
|
## Why Beads?
|
||||||
|
|
||||||
|
✨ **AI-Native Design**
|
||||||
|
- Built specifically for AI-assisted development workflows
|
||||||
|
- CLI-first interface works seamlessly with AI coding agents
|
||||||
|
- No context switching to web UIs
|
||||||
|
|
||||||
|
🚀 **Developer Focused**
|
||||||
|
- Issues live in your repo, right next to your code
|
||||||
|
- Works offline, syncs when you push
|
||||||
|
- Fast, lightweight, and stays out of your way
|
||||||
|
|
||||||
|
🔧 **Git Integration**
|
||||||
|
- Dolt-native sync via bd dolt push / bd dolt pull
|
||||||
|
- Branch-aware issue tracking
|
||||||
|
- Dolt-native three-way merge resolution
|
||||||
|
|
||||||
|
## Get Started with Beads
|
||||||
|
|
||||||
|
Try Beads in your own projects:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Beads
|
||||||
|
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
|
||||||
|
|
||||||
|
# Initialize in your repo
|
||||||
|
bd init
|
||||||
|
|
||||||
|
# Create your first issue
|
||||||
|
bd create "Try out Beads"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
|
||||||
|
- **Quick Start Guide**: Run `bd quickstart`
|
||||||
|
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Beads: Issue tracking that moves at the speed of thought* ⚡
|
||||||
68
.beads/config.yaml
Normal file
68
.beads/config.yaml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Beads Configuration File
|
||||||
|
# This file configures default behavior for all bd commands in this repository
|
||||||
|
# All settings can also be set via environment variables (BD_* prefix)
|
||||||
|
# or overridden with command-line flags
|
||||||
|
|
||||||
|
# Issue prefix for this repository (used by bd init)
|
||||||
|
# If not set, bd init will auto-detect from directory name
|
||||||
|
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
|
||||||
|
# issue-prefix: ""
|
||||||
|
|
||||||
|
# Use no-db mode: JSONL-only, no Dolt database
|
||||||
|
# When true, .beads/issues.jsonl is the only local store
|
||||||
|
# no-db: false
|
||||||
|
|
||||||
|
# Enable JSON output by default
|
||||||
|
# json: false
|
||||||
|
|
||||||
|
# Feedback title formatting for mutating commands (create/update/close/dep/edit)
|
||||||
|
# 0 = hide titles, N > 0 = truncate to N characters
|
||||||
|
# output:
|
||||||
|
# title-length: 255
|
||||||
|
|
||||||
|
# Default actor for audit trails (overridden by BEADS_ACTOR or --actor)
|
||||||
|
# actor: ""
|
||||||
|
|
||||||
|
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
|
||||||
|
# When enabled, new events are appended incrementally using a high-water mark.
|
||||||
|
# Use 'bd export --events' to trigger manually regardless of this setting.
|
||||||
|
# events-export: false
|
||||||
|
|
||||||
|
# Multi-repo configuration (experimental - bd-307)
|
||||||
|
# Allows hydrating from multiple repositories and routing writes to the correct database
|
||||||
|
# repos:
|
||||||
|
# primary: "." # Primary repo (where this database lives)
|
||||||
|
# additional: # Additional repos to hydrate from (read-only)
|
||||||
|
# - ~/beads-planning # Personal planning repo
|
||||||
|
# - ~/work-planning # Work planning repo
|
||||||
|
|
||||||
|
# Dolt-native backup (periodic backup for off-machine recovery)
|
||||||
|
# This is full database backup only. Cross-machine sync uses Dolt remotes.
|
||||||
|
# backup:
|
||||||
|
# enabled: false # Disable auto-backup entirely
|
||||||
|
# interval: 15m # Minimum time between auto-backups
|
||||||
|
# git-push: false # Disable git push (backup locally only)
|
||||||
|
# git-repo: "" # Separate git repo for backups (default: project repo)
|
||||||
|
|
||||||
|
# Optional JSONL auto-export for viewers, interchange, and issue-level migration.
|
||||||
|
# Disabled by default; enable only when an integration needs fresh .beads/issues.jsonl.
|
||||||
|
# Use relative paths under .beads/ for JSONL import/export filenames.
|
||||||
|
# export:
|
||||||
|
# auto: false
|
||||||
|
# path: issues.jsonl
|
||||||
|
# interval: 60s
|
||||||
|
# git-add: false
|
||||||
|
# import:
|
||||||
|
# path: issues.jsonl
|
||||||
|
|
||||||
|
# Integration settings (access with 'bd config get/set')
|
||||||
|
# Non-secret keys (stored in the database):
|
||||||
|
# - jira.url, jira.project
|
||||||
|
# - linear.team_id
|
||||||
|
# - github.org, github.repo
|
||||||
|
#
|
||||||
|
# Secret keys (stored in this file but prefer env vars to avoid git exposure):
|
||||||
|
# - linear.api_key → use LINEAR_API_KEY env var instead
|
||||||
|
# - github.token → use GITHUB_TOKEN env var instead
|
||||||
|
|
||||||
|
sync.remote: "git+ssh://git@git.shinuwa.fr:2222/shinuwa/sokko-g.git"
|
||||||
33
.beads/hooks/post-checkout
Executable file
33
.beads/hooks/post-checkout
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.2.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
_bd_used_perl=0
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run post-checkout "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v gtimeout >/dev/null 2>&1; then
|
||||||
|
gtimeout "$_bd_timeout" bd hooks run post-checkout "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v perl >/dev/null 2>&1; then
|
||||||
|
_bd_used_perl=1
|
||||||
|
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run post-checkout "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
else
|
||||||
|
echo >&2 "beads: hook 'post-checkout' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
|
||||||
|
bd hooks run post-checkout "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
|
||||||
|
echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'post-checkout'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.2.2 ---
|
||||||
33
.beads/hooks/post-merge
Executable file
33
.beads/hooks/post-merge
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.2.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
_bd_used_perl=0
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run post-merge "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v gtimeout >/dev/null 2>&1; then
|
||||||
|
gtimeout "$_bd_timeout" bd hooks run post-merge "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v perl >/dev/null 2>&1; then
|
||||||
|
_bd_used_perl=1
|
||||||
|
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run post-merge "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
else
|
||||||
|
echo >&2 "beads: hook 'post-merge' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
|
||||||
|
bd hooks run post-merge "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
|
||||||
|
echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'post-merge'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.2.2 ---
|
||||||
33
.beads/hooks/pre-commit
Executable file
33
.beads/hooks/pre-commit
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.2.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
_bd_used_perl=0
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run pre-commit "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v gtimeout >/dev/null 2>&1; then
|
||||||
|
gtimeout "$_bd_timeout" bd hooks run pre-commit "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v perl >/dev/null 2>&1; then
|
||||||
|
_bd_used_perl=1
|
||||||
|
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run pre-commit "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
else
|
||||||
|
echo >&2 "beads: hook 'pre-commit' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
|
||||||
|
bd hooks run pre-commit "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
|
||||||
|
echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'pre-commit'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.2.2 ---
|
||||||
33
.beads/hooks/pre-push
Executable file
33
.beads/hooks/pre-push
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.2.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
_bd_used_perl=0
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run pre-push "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v gtimeout >/dev/null 2>&1; then
|
||||||
|
gtimeout "$_bd_timeout" bd hooks run pre-push "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v perl >/dev/null 2>&1; then
|
||||||
|
_bd_used_perl=1
|
||||||
|
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run pre-push "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
else
|
||||||
|
echo >&2 "beads: hook 'pre-push' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
|
||||||
|
bd hooks run pre-push "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
|
||||||
|
echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'pre-push'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.2.2 ---
|
||||||
33
.beads/hooks/prepare-commit-msg
Executable file
33
.beads/hooks/prepare-commit-msg
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.2.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
_bd_used_perl=0
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v gtimeout >/dev/null 2>&1; then
|
||||||
|
gtimeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
elif command -v perl >/dev/null 2>&1; then
|
||||||
|
_bd_used_perl=1
|
||||||
|
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
else
|
||||||
|
echo >&2 "beads: hook 'prepare-commit-msg' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
|
||||||
|
bd hooks run prepare-commit-msg "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
|
||||||
|
echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.2.2 ---
|
||||||
77
.beads/interactions.jsonl
Normal file
77
.beads/interactions.jsonl
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{"id":"int-8d5f8f5714677d91360a20177b1f5a9e","kind":"field_change","created_at":"2026-08-27T12:58:35.614943857Z","actor":"Shinuwa","issue_id":"sokko-g-lki","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-206ccb97f9d09712e2b4136d115a0e43","kind":"field_change","created_at":"2026-08-27T13:09:45.211867088Z","actor":"Shinuwa","issue_id":"sokko-g-e7s","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed remaining toolbox control migration to NDS mixins and updated design-system catalogue examples."}}
|
||||||
|
{"id":"int-5131e4130f57a4e8a55f176a46240fad","kind":"field_change","created_at":"2026-08-27T16:19:44.570963534Z","actor":"Shinuwa","issue_id":"sokko-g-npt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed NDS button variant cleanup and catalogue simplification."}}
|
||||||
|
{"id":"int-3db2dfcbabd4cbb63eaeedb1bea8ab47","kind":"field_change","created_at":"2026-08-27T16:43:20.612129607Z","actor":"Shinuwa","issue_id":"sokko-g-0o4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed NDS data attribute API migration and validation."}}
|
||||||
|
{"id":"int-578a8a6aff782082d539f0e14bb0aa79","kind":"field_change","created_at":"2026-08-27T16:58:07.410812508Z","actor":"Shinuwa","issue_id":"sokko-g-alp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Expanded /design-system into exhaustive NDS family attribute matrices."}}
|
||||||
|
{"id":"int-cc32f7330c12cf2d37b0d22650ccfb5e","kind":"field_change","created_at":"2026-08-27T17:13:59.871782552Z","actor":"Shinuwa","issue_id":"sokko-g-pp5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reworked /design-system into component cards with configurable attribute matrices."}}
|
||||||
|
{"id":"int-17da0005e87891c66734a766d8456459","kind":"field_change","created_at":"2026-08-27T17:23:07.045839669Z","actor":"Shinuwa","issue_id":"sokko-g-73i","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-d0531629593c076b63281346c729ac3e","kind":"field_change","created_at":"2026-08-27T17:28:11.740302589Z","actor":"Shinuwa","issue_id":"sokko-g-wnh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-fb41489d0517f51fcda643c4230461fe","kind":"field_change","created_at":"2026-08-27T17:35:40.407737936Z","actor":"Shinuwa","issue_id":"sokko-g-s0a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-b7bafbc214af080356e07d2d97f1ffba","kind":"field_change","created_at":"2026-08-27T21:32:26.611249333Z","actor":"Shinuwa","issue_id":"sokko-g-2u9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-ea50d03c7c84b32ab2fce86f15bbe05f","kind":"field_change","created_at":"2026-08-27T21:43:00.775108889Z","actor":"Shinuwa","issue_id":"sokko-g-bh4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Completed"}}
|
||||||
|
{"id":"int-4f7a574335bb2d616e9caae09e22e55b","kind":"field_change","created_at":"2026-08-27T21:45:18.547229481Z","actor":"Shinuwa","issue_id":"sokko-g-7jm","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-ae5fb0bbe41a7958929560a1bbf65052","kind":"field_change","created_at":"2026-08-27T21:48:45.268794089Z","actor":"Shinuwa","issue_id":"sokko-g-z2t","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-8e3b38de72d948a6c241b0d8b2f8077b","kind":"field_change","created_at":"2026-08-27T21:54:30.02159376Z","actor":"Shinuwa","issue_id":"sokko-g-coh","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Completed"}}
|
||||||
|
{"id":"int-0d86b94b9a04df5b68e2362e0824a37b","kind":"field_change","created_at":"2026-08-27T21:57:24.401323676Z","actor":"Shinuwa","issue_id":"sokko-g-hif","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed"}}
|
||||||
|
{"id":"int-3fcb6bc2b605db6210c0da18da07e902","kind":"field_change","created_at":"2026-08-27T22:01:35.180268661Z","actor":"Shinuwa","issue_id":"sokko-g-6fs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed NDS textarea min heights by size"}}
|
||||||
|
{"id":"int-611cb19bdd31f0e79cc8c2f2bb7a3dd7","kind":"field_change","created_at":"2026-08-28T07:13:54.812999765Z","actor":"Shinuwa","issue_id":"sokko-g-tvo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed NDS field hover cascade and replaced public date trigger with select trigger"}}
|
||||||
|
{"id":"int-0e1196655dabd3096c10beef1ba8aff2","kind":"field_change","created_at":"2026-08-28T07:19:28.131918475Z","actor":"Shinuwa","issue_id":"sokko-g-z4x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed select trigger hover chevron and focus-visible outline"}}
|
||||||
|
{"id":"int-9c27343c043121862608b8ed9013c306","kind":"field_change","created_at":"2026-08-28T07:25:53.259127006Z","actor":"Shinuwa","issue_id":"sokko-g-eln","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed select trigger md hover/focus and disabled checkbox styling"}}
|
||||||
|
{"id":"int-da5413e82c18d19a8432288bec703dd7","kind":"field_change","created_at":"2026-08-28T07:50:07.033524644Z","actor":"Shinuwa","issue_id":"sokko-g-h7z","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed global NDS control migration and validation"}}
|
||||||
|
{"id":"int-42f39cb889dfab3bc998d4ce566d2339","kind":"field_change","created_at":"2026-08-28T08:09:50.738078383Z","actor":"Shinuwa","issue_id":"sokko-g-9p1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed filter radio group, sidebar storage buttons, memo controls, and textarea ghost variant"}}
|
||||||
|
{"id":"int-03ed1c823da90551f51a5a0d5691e440","kind":"field_change","created_at":"2026-08-28T12:35:29.257332022Z","actor":"Shinuwa","issue_id":"sokko-g-39h","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Applied NDS event-surface to calendar week and month events, documented data-color/data-size, and validated checks."}}
|
||||||
|
{"id":"int-88c39f90fdd5b81110acc4bdae55836c","kind":"field_change","created_at":"2026-09-10T11:49:36.768770213Z","actor":"Shinuwa","issue_id":"sokko-g-vfo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared lazy React registry consumed by toolbox and library; obsolete static import assertions removed; all 17 library types exercised; npm run check and 13 E2E tests pass."}}
|
||||||
|
{"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}}
|
||||||
|
{"id":"int-1668982a1dda5d8304440ae699a32d40","kind":"field_change","created_at":"2026-09-10T12:22:11.969660039Z","actor":"Shinuwa","issue_id":"sokko-g-5u5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recursive cache enabled for completed evaluations. Before: 131054 reads for 16 cells; after: 50 reads for 50 cells. Regression test failed before fix and passes after; npm run check and table persistence E2E pass."}}
|
||||||
|
{"id":"int-903a25fde459880bbbadc2764da920a2","kind":"field_change","created_at":"2026-09-10T16:58:56.754421196Z","actor":"Shinuwa","issue_id":"sokko-g-hf6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Unified confirmed-state queue for all writes; imports use current-state updaters; atomic creation/link/deletion and synchronous transaction abort. Removed obsolete checkpoints. Deterministic concurrency/failure tests and real IndexedDB abort E2E pass; full suite 15/15 and npm run check pass."}}
|
||||||
|
{"id":"int-16ba759581961e395e133df1c6019895","kind":"field_change","created_at":"2026-09-10T17:21:13.173981957Z","actor":"Shinuwa","issue_id":"sokko-g-ra9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}}
|
||||||
|
{"id":"int-df984435537f78d51a7a309feda1109c","kind":"field_change","created_at":"2026-09-10T17:21:18.79044889Z","actor":"Shinuwa","issue_id":"sokko-g-ckq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}}
|
||||||
|
{"id":"int-300128a7d6ed7fa43c5f43c7cb0b9dad","kind":"field_change","created_at":"2026-09-11T06:50:23.200542022Z","actor":"Shinuwa","issue_id":"sokko-g-v02","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Default E2E concurrency capped at two workers with failure traces; compact calendar titles kept in flow and sm padding corrected. Desktop/mobile geometry and editing regressions added. npm run check:full passed; second full E2E run passed all 17 tests without retries."}}
|
||||||
|
{"id":"int-a54950bb4a7dbb177188a5ec02e9d9fd","kind":"field_change","created_at":"2026-09-11T06:50:28.175047619Z","actor":"Shinuwa","issue_id":"sokko-g-328","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Default E2E concurrency capped at two workers with failure traces; compact calendar titles kept in flow and sm padding corrected. Desktop/mobile geometry and editing regressions added. npm run check:full passed; second full E2E run passed all 17 tests without retries."}}
|
||||||
|
{"id":"int-147c4573ce40577c21afec08775c2811","kind":"field_change","created_at":"2026-09-11T07:16:09.749977471Z","actor":"Shinuwa","issue_id":"sokko-g-55a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Restored recommended purity lint; initialized alert clock in effect; removed post-commit draft synchronization from shared inline editing and reused it in Randomizer. 100 Node tests, lint, production build and all 19 E2E pass. StrictMode hook tests cover input/contentEditable, source updates, commit and Escape; broader C10 tracked in sokko-g-8po."}}
|
||||||
|
{"id":"int-f9e84a155b896c7f68c83ee2454f904d","kind":"field_change","created_at":"2026-09-11T11:56:43.680278965Z","actor":"Shinuwa","issue_id":"sokko-g-sc7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproduced memo draft loss on notification, isolated per-memo drafts using useInlineEdit, preserved multiline/trim/delete behavior. Goal Tree reconciles selection and valid tabs before DOM commit with StrictMode regressions. npm run check:full passed: 100 Node tests and 21 E2E."}}
|
||||||
|
{"id":"int-4b64d68baf132fa99cb3244d8487034c","kind":"field_change","created_at":"2026-09-11T12:05:04.580370366Z","actor":"Shinuwa","issue_id":"sokko-g-kqo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared keyed state initializes Notepad/ImageAnnotation temporary drawings and RNG history before DOM commit; ignores setters bound to a different active key. Storage keys and write/error paths preserved. npm run check:full passed: 100 Node tests and 23 E2E including StrictMode key switches and real Notepad canvas/page/reload checks."}}
|
||||||
|
{"id":"int-80659dd55d71f2a33d1067b008bf2abd","kind":"field_change","created_at":"2026-09-11T17:30:09.794064421Z","actor":"Shinuwa","issue_id":"sokko-g-275","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed RNG draft/edit transitions and ImageViewer pre-commit resets; cover decoding now guards stale completion/errors and releases resources. Four new StrictMode E2E cases pass; npm run check:full passes with 27 E2E. No storage or style changes."}}
|
||||||
|
{"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
|
{"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
|
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
|
{"id":"int-c7fa4c94461f676392625658508ae231","kind":"field_change","created_at":"2026-09-12T10:15:48.480122568Z","actor":"Shinuwa","issue_id":"sokko-g-5rq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Notepad now separates page identity from focused editing, replaces the editor DOM on page/module changes, preserves selection during local echoes and applies external updates after blur without stealing focus. Removed obsolete selection helpers and timestamp-only echo detection. check:full passes with 36 E2E; global set-state-in-effect diagnostic passes."}}
|
||||||
|
{"id":"int-e79db6c1131fb3c5c57dde09917ef735","kind":"field_change","created_at":"2026-09-12T10:50:44.387707762Z","actor":"Shinuwa","issue_id":"sokko-g-8po","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Enabled app-wide StrictMode, restored recommended react-hooks/set-state-in-effect rule, documented development checks and added an effective ESLint configuration regression test. npm run check:full passed: lint, 101 Node tests, build and 36 E2E. All C10 subsets complete."}}
|
||||||
|
{"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}}
|
||||||
|
{"id":"int-5a1d6dccbbcebf69807b6134785175ef","kind":"field_change","created_at":"2026-09-12T18:30:49.351851361Z","actor":"Shinuwa","issue_id":"sokko-g-d5u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Protected IndexedDB bootstrap: readiness granted only after complete successful reads; all store writes rejected before initialization; create/import/export guards; startup error/loading gate with non-destructive retry; rejected connection promise evicted for reopening; generic accurate write-error title. Editorial strings validated and storage/workflow docs updated. Four new E2E cover repeated open/read failures, preserved data and exports, creation after retry, direct command guards and later write errors. Desktop/mobile screenshots reviewed. npm run check:full passed lint,101 Node tests,build,44 E2E. No schema or format changes."}}
|
||||||
|
{"id":"int-224b7820169f57116ea4fd87a50560f1","kind":"field_change","created_at":"2026-09-12T21:26:21.88315065Z","actor":"Shinuwa","issue_id":"sokko-g-bhy","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented selective images/imageAnnotation hydration with local retry, preserved unread values in atomic snapshots, and serialized complete exports without populating the UI cache. No schema change; timer/calendar remain eager. check:full passed including 52 E2E; four production scenarios profiled alone, five reloads each. 40-media baseline 120.68 to 2.78 MiB JS heap, 610.1 to 445.4 ms median ready. Reports in .local/performance; docs updated. Opened media remain cached until reload. No git commit or push performed."}}
|
||||||
|
{"id":"int-4036248c53312cfaafae4ee37bb145bd","kind":"field_change","created_at":"2026-09-12T21:26:40.724321767Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-37bf322d7d6ae9d06e033d9f16b7f057","kind":"field_change","created_at":"2026-09-12T21:33:50.250082802Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-cc87d51843f7ee62beb6445939aa30d5","kind":"field_change","created_at":"2026-09-13T06:35:52.804580049Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-e554bed2958d709f2965a55fea9aa956","kind":"field_change","created_at":"2026-09-13T06:53:17.211928645Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-6f399629fa8cc8212d36ca21853eaa52","kind":"field_change","created_at":"2026-09-13T17:46:00.430152831Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-3823a391b02ae7a3d58956b24c10e998","kind":"field_change","created_at":"2026-09-13T18:43:29.457174095Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-e3a2bea901894d43a31eb628b588648a","kind":"field_change","created_at":"2026-09-14T07:08:33.027716428Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-20d1e0d862e6155eadf2c36eced314db","kind":"field_change","created_at":"2026-09-14T11:56:36.962825878Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-cc67f2645165e0eac9f57d71e8a34e57","kind":"field_change","created_at":"2026-09-14T12:11:10.207360007Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-f9245b926aa0f581b75f6b5904778e4e","kind":"field_change","created_at":"2026-09-14T13:08:47.57641261Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-bb9e0e4a748e0fa71f7a88e8d5f1b9c1","kind":"field_change","created_at":"2026-09-17T15:41:40.939310492Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-8ce072a6196bda5d63c77776fe0a1c14","kind":"field_change","created_at":"2026-09-17T16:06:03.481316789Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-d8c4d7fe12bbb2589779d4fe3d920ba7","kind":"field_change","created_at":"2026-09-17T20:29:55.005289442Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-3504141284535c146e3b392ad6bf43c8","kind":"field_change","created_at":"2026-09-17T21:51:54.241158283Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed agreed risk-based C06 scope on 2026-09-17: four final behavioral scenarios cover persistent Links, Counters, Checklist and planner subtree reorder; removed ten redundant implementation assertions. Coverage and optional extensions documented in docs/TEST_STRATEGY.md. npm run check:full passed: 107 Node tests, 113 development E2E and 1 production E2E. Exhaustive regex conversion and advanced permutations are explicitly outside closure scope."}}
|
||||||
|
{"id":"int-c3ca1d1966e94ad60e50b7651e69dd3c","kind":"field_change","created_at":"2026-10-01T13:07:29.412082155Z","actor":"Shinuwa","issue_id":"sokko-g-e0w","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-44218df9c59fe7b1e7751e0e5beda446","kind":"field_change","created_at":"2026-10-01T13:07:35.178021378Z","actor":"Shinuwa","issue_id":"sokko-g-hou","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-37af39aa8afb028e1b0a4ee7cb560e6f","kind":"field_change","created_at":"2026-10-01T13:07:41.11959149Z","actor":"Shinuwa","issue_id":"sokko-g-u3q","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-9aae527fb549b8615715abbb9866f7a4","kind":"field_change","created_at":"2026-10-01T13:07:48.244772328Z","actor":"Shinuwa","issue_id":"sokko-g-07n","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-4802ad9fa9819b02b2e80eca85a922e1","kind":"field_change","created_at":"2026-10-01T13:07:54.094982019Z","actor":"Shinuwa","issue_id":"sokko-g-116","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-8347873f5e7c633e42971023b30e2ebc","kind":"field_change","created_at":"2026-10-01T13:07:59.694616997Z","actor":"Shinuwa","issue_id":"sokko-g-04z","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-fa3cdcfa1cb186976fa055fa3d9c4a4d","kind":"field_change","created_at":"2026-10-01T13:08:04.727809097Z","actor":"Shinuwa","issue_id":"sokko-g-8gh","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-85cdca7795af3cdbd4729f959d8b5c81","kind":"field_change","created_at":"2026-10-01T13:08:09.98617258Z","actor":"Shinuwa","issue_id":"sokko-g-p1f","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-bd78f7eb64c9b7a5d617a9a856903a9c","kind":"field_change","created_at":"2026-10-01T13:08:15.31986785Z","actor":"Shinuwa","issue_id":"sokko-g-wjm","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-710f7ce7796af7f1398522cc5daa6e85","kind":"field_change","created_at":"2026-10-01T13:08:21.047364209Z","actor":"Shinuwa","issue_id":"sokko-g-kyz","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-6f6ea6c870883b1a82580a46bd0df5a2","kind":"field_change","created_at":"2026-10-01T13:08:25.569047764Z","actor":"Shinuwa","issue_id":"sokko-g-4i9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-4b71144d1e015bb3fa6cf94d5fb2c90b","kind":"field_change","created_at":"2026-10-01T13:08:30.477630845Z","actor":"Shinuwa","issue_id":"sokko-g-mag","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-73ab6f8b2af7fdd86148531fd1eacb65","kind":"field_change","created_at":"2026-10-01T13:08:36.827446402Z","actor":"Shinuwa","issue_id":"sokko-g-fpd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Delivered on main in the commits recorded in the issue description; reconstructed in Beads on 2026-10-01."}}
|
||||||
|
{"id":"int-1ee4fbc06ba1ca47d57a3938ce5c647a","kind":"field_change","created_at":"2026-10-01T13:17:28.985204569Z","actor":"Shinuwa","issue_id":"sokko-g-aut","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Backfilled 13 retrospective issues covering all 23 commits, validated the repository, initialized the empty Dolt remote, and published the complete Beads history."}}
|
||||||
|
{"id":"int-e642f21f88bfa85d4b4ba7746e994cd3","kind":"field_change","created_at":"2026-10-03T11:31:45.398002709Z","actor":"Shinuwa","issue_id":"sokko-g-noo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented Memos planner linking, reset warning ticker items, deletion cleanup, import/export remapping, documentation, unit coverage, and Playwright coverage. npm run check and the focused Memos E2E suite pass."}}
|
||||||
|
{"id":"int-e4050bca9a4c650f7447b7dac6c81ebb","kind":"field_change","created_at":"2026-10-03T13:02:57.878785474Z","actor":"Shinuwa","issue_id":"sokko-g-8wa","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented shared toolbox configuration panels, migrated tool settings and text exchange, updated responsive styles, docs, and tests."}}
|
||||||
|
{"id":"int-c4a9c3aa90751550c16cf7e201af7259","kind":"field_change","created_at":"2026-10-03T13:40:04.048932123Z","actor":"Shinuwa","issue_id":"sokko-g-0x3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Refined Configuration panel into a compact integrated layout with Organisation and Data on the first row, full-width tool settings below, shorter visible data actions, responsive stacking, documentation, and E2E coverage."}}
|
||||||
|
{"id":"int-11873ba8e71cce0b835e8d8d2124359b","kind":"field_change","created_at":"2026-10-03T13:48:06.348778272Z","actor":"Shinuwa","issue_id":"sokko-g-mwn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Aligned the Espace select and import/export actions at small size, removed the redundant visible move label, retained accessible naming, and updated documentation and tests."}}
|
||||||
|
{"id":"int-66c90a8d8067d5ef4113abfb6bb08acb","kind":"field_change","created_at":"2026-10-04T07:35:34.548239131Z","actor":"Shinuwa","issue_id":"sokko-g-s8f","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Duplicate created during CLI retry"}}
|
||||||
|
{"id":"int-47ba58044a85b44f70cb202f254b6913","kind":"field_change","created_at":"2026-10-04T07:37:20.762939126Z","actor":"Shinuwa","issue_id":"sokko-g-b0e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reused the shared module header layout for library tool previews; full validation passes."}}
|
||||||
7
.beads/metadata.json
Normal file
7
.beads/metadata.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"database": "dolt",
|
||||||
|
"backend": "dolt",
|
||||||
|
"dolt_mode": "embedded",
|
||||||
|
"dolt_database": "sokko_g",
|
||||||
|
"project_id": "383fe8d1-bd71-460f-9ff5-e71f14df4827"
|
||||||
|
}
|
||||||
15
.claude/settings.json
Normal file
15
.claude/settings.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "bd prime --hook-json",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.codex/config.toml
Normal file
2
.codex/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[features]
|
||||||
|
hooks = true
|
||||||
51
.codex/hooks.json
Normal file
51
.codex/hooks.json
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "bd codex-hook PostCompact",
|
||||||
|
"statusMessage": "Scheduling Beads context refresh",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": "manual|auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "bd codex-hook PreCompact",
|
||||||
|
"statusMessage": "Checking Beads context",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": "manual|auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "bd codex-hook SessionStart",
|
||||||
|
"statusMessage": "Loading Beads context",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": "startup|resume|clear"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "bd codex-hook UserPromptSubmit",
|
||||||
|
"statusMessage": "Refreshing Beads context",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
PORT=5173
|
||||||
30
.forgejo/workflows/deploy-prod.yml
Normal file
30
.forgejo/workflows/deploy-prod.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
name: Deploy Sokko G
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy on WebApps
|
||||||
|
env:
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
if ! command -v ssh >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends openssh-client
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=no \
|
||||||
|
-i ~/.ssh/id_ed25519 \
|
||||||
|
-p 2911 \
|
||||||
|
shinuwa@192.168.0.100 \
|
||||||
|
"sudo -u shinuwa git -C /data/webapps/sites/sokko-g clean -f -- package-lock.json && sudo /usr/local/bin/deploy-sokko-g"
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
node_modules/
|
||||||
|
website/dist/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.local/
|
||||||
|
|
||||||
|
# Beads / Dolt files (added by bd init)
|
||||||
|
.dolt/
|
||||||
|
*.db
|
||||||
|
.beads-credential-key
|
||||||
|
.beads/proxieddb/
|
||||||
30
AGENTS.md
Normal file
30
AGENTS.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
Sokko G est une webapp React + Vite + Sass centrée sur des toolboxes locales et des pages de jeu.
|
||||||
|
|
||||||
|
## Portée
|
||||||
|
|
||||||
|
Les règles détaillées sont réparties par zone pour limiter le contexte chargé à chaque tâche :
|
||||||
|
|
||||||
|
- `website/AGENTS.md` : application React, contenu éditable, serveur de développement, validation.
|
||||||
|
- `website/src/features/toolboxes/AGENTS.md` : outils toolbox, stockage IndexedDB, import/export, hooks partagés.
|
||||||
|
- `website/src/styles/AGENTS.md` : Sass, design system, composants visuels et patterns UI.
|
||||||
|
- `docs/AGENTS.md` : documentation projet, schémas de stockage et checklists.
|
||||||
|
- `tests/AGENTS.md` : validations de données et tests.
|
||||||
|
|
||||||
|
Lire le fichier d'instructions le plus proche du code modifié avant d'intervenir.
|
||||||
|
|
||||||
|
## Règles globales
|
||||||
|
|
||||||
|
- Utiliser `rg`, `git status --short`, `git diff --stat` et les commandes ciblées avant les recherches larges.
|
||||||
|
- Concevoir l'interface pour un ordinateur utilisé en plein écran ou en demi-écran, avec `768px` comme largeur minimale validée. Ne pas créer de navigation, de composant ou de parcours propre au téléphone. Sous `768px`, préserver seulement l'accès au contenu et empêcher les débordements globaux, sans optimiser le confort d'usage.
|
||||||
|
- Ne pas réintroduire de fallback massif type `DEFAULT_SITE_CONTENT` dans le code React.
|
||||||
|
- Chaque nouveau fichier source ou test commence par un commentaire court `Rôle : ...` dans le style du langage.
|
||||||
|
- Ne pas lancer automatiquement un serveur sur `5174` si `5173` répond déjà.
|
||||||
|
- Avant de terminer une modification significative, lancer `npm run check`.
|
||||||
|
- Quand l'utilisateur indique qu'il va push, traiter le message comme une demande implicite de pré-push check : relire le diff, lancer les validations pertinentes et fournir un message de commit. Ne pas exécuter `git commit`, `git push` ou `git sync` sauf demande explicite.
|
||||||
|
- Proposer les messages de commit sous forme de phrase simple en anglais, sans préfixe Conventional Commits (`fix(...)`, `feat:`, etc.).
|
||||||
|
|
||||||
|
## Beads
|
||||||
|
|
||||||
|
Ce dépôt utilise Beads (`bd`) pour le suivi durable des tâches. Utiliser `bd prime` quand un workflow Beads est nécessaire, et ne pas commit/push/sync sans demande explicite.
|
||||||
77
CLAUDE.md
Normal file
77
CLAUDE.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Project Instructions for AI Agents
|
||||||
|
|
||||||
|
This file provides instructions and context for AI coding agents working on this project.
|
||||||
|
|
||||||
|
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:6cd5cc61 -->
|
||||||
|
## Beads Issue Tracker
|
||||||
|
|
||||||
|
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready # Find available work
|
||||||
|
bd show <id> # View issue details
|
||||||
|
bd update <id> --claim # Claim work
|
||||||
|
bd close <id> # Complete work
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
||||||
|
- Run `bd prime` for detailed command reference and session close protocol
|
||||||
|
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
||||||
|
|
||||||
|
**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns.
|
||||||
|
|
||||||
|
## Agent Context Profiles
|
||||||
|
|
||||||
|
The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions.
|
||||||
|
|
||||||
|
- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands.
|
||||||
|
- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise.
|
||||||
|
- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins.
|
||||||
|
|
||||||
|
## Session Completion
|
||||||
|
|
||||||
|
This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions.
|
||||||
|
|
||||||
|
1. **File issues for remaining work** - Create beads for anything that needs follow-up
|
||||||
|
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||||
|
3. **Update issue status** - Close finished work, update in-progress items
|
||||||
|
4. **Handle git/sync by active profile**:
|
||||||
|
```bash
|
||||||
|
# Conservative/minimal/default: report status and proposed commands; wait for approval.
|
||||||
|
git status
|
||||||
|
|
||||||
|
# Team-maintainer opt-in only, unless current instructions forbid it:
|
||||||
|
git pull --rebase
|
||||||
|
git push
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step
|
||||||
|
|
||||||
|
**Critical rules:**
|
||||||
|
- Explicit user or orchestrator instructions override this Beads block.
|
||||||
|
- Do not commit or push without clear authority from the active profile or the current user request.
|
||||||
|
- If a required sync or push is blocked, stop and report the exact command and error.
|
||||||
|
<!-- END BEADS INTEGRATION -->
|
||||||
|
|
||||||
|
|
||||||
|
## Build & Test
|
||||||
|
|
||||||
|
_Add your build and test commands here_
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example:
|
||||||
|
# npm install
|
||||||
|
# npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
_Add a brief overview of your project architecture_
|
||||||
|
|
||||||
|
## Conventions & Patterns
|
||||||
|
|
||||||
|
_Add your project-specific conventions here_
|
||||||
981
DESIGN_SYSTEM.md
Normal file
981
DESIGN_SYSTEM.md
Normal file
|
|
@ -0,0 +1,981 @@
|
||||||
|
# Nebula Design System - Sokko G
|
||||||
|
|
||||||
|
Ce document est la reference visuelle du projet Sokko G. Toute nouvelle interface doit rester coherente avec le theme **Nebula Command** actuel et les controles du **Nebula Design System**.
|
||||||
|
|
||||||
|
## 1. Vision
|
||||||
|
|
||||||
|
Sokko G est une webapp gaming locale, pensee pour rester ouverte sur un second ecran pendant une session. L'interface doit etre sombre, lisible, dense sans etre surchargee, et suffisamment immersive pour evoquer une ambiance nebuleuse premium.
|
||||||
|
|
||||||
|
Principes directeurs :
|
||||||
|
|
||||||
|
- priorite a la lisibilite pendant de longues sessions ;
|
||||||
|
- pas de style RGB agressif ;
|
||||||
|
- violet et indigo pour la profondeur et les interactions ;
|
||||||
|
- dore pour les accents de hierarchie, les eyebrows, les focus et certaines bordures ;
|
||||||
|
- cyan conserve comme token secondaire, mais non dominant ;
|
||||||
|
- les effets lumineux doivent rester contenus.
|
||||||
|
|
||||||
|
## 2. Tokens
|
||||||
|
|
||||||
|
Les tokens source sont dans `website/src/styles/_tokens.scss`. Ne pas dupliquer les couleurs dans les composants.
|
||||||
|
|
||||||
|
Couleurs principales :
|
||||||
|
|
||||||
|
```css
|
||||||
|
--color-bg-page: #070913;
|
||||||
|
--color-bg-deep: #050711;
|
||||||
|
--color-bg-sidebar: #0b0e1b;
|
||||||
|
--color-bg-surface: #101426;
|
||||||
|
--color-bg-surface-alt: #151a30;
|
||||||
|
--color-bg-elevated: #1a2038;
|
||||||
|
--color-bg-hover: #202745;
|
||||||
|
|
||||||
|
--color-primary: #8b5cf6;
|
||||||
|
--color-primary-hover: #9f7aea;
|
||||||
|
--color-primary-active: #7347dc;
|
||||||
|
--color-primary-soft: rgba(139, 92, 246, 0.14);
|
||||||
|
--color-primary-border: rgba(139, 92, 246, 0.45);
|
||||||
|
|
||||||
|
--color-accent-gold: #f6c453;
|
||||||
|
--color-accent-cyan: #22d3ee;
|
||||||
|
--color-accent-pink: #d946ef;
|
||||||
|
--color-accent-indigo: #6366f1;
|
||||||
|
|
||||||
|
--color-text-primary: #f5f7ff;
|
||||||
|
--color-text-secondary: #b4bdd3;
|
||||||
|
--color-text-muted: #7d879f;
|
||||||
|
|
||||||
|
--color-border: rgba(150, 165, 205, 0.14);
|
||||||
|
--color-border-hover: rgba(150, 165, 205, 0.28);
|
||||||
|
```
|
||||||
|
|
||||||
|
Rayons :
|
||||||
|
|
||||||
|
- `--radius-sm: 6px`
|
||||||
|
- `--radius-md: 10px`
|
||||||
|
- `--radius-lg: 14px`
|
||||||
|
- `--radius-xl: 18px`
|
||||||
|
- `--radius-pill: 999px`
|
||||||
|
|
||||||
|
Espacements :
|
||||||
|
|
||||||
|
- `--space-1: 0.25rem`
|
||||||
|
- `--space-2: 0.5rem`
|
||||||
|
- `--space-3: 0.75rem`
|
||||||
|
- `--space-4: 1rem`
|
||||||
|
- `--space-5: 1.25rem`
|
||||||
|
- `--space-6: 1.5rem`
|
||||||
|
- `--space-8: 2rem`
|
||||||
|
- `--space-10: 2.5rem`
|
||||||
|
- `--space-12: 3rem`
|
||||||
|
|
||||||
|
Animations :
|
||||||
|
|
||||||
|
- `--duration-fast: 120ms`
|
||||||
|
- `--duration-normal: 180ms`
|
||||||
|
- `--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||||
|
|
||||||
|
## 3. API Publique NDS
|
||||||
|
|
||||||
|
Le Nebula Design System expose ses primitives publiques avec une classe de famille et des attributs `data-*`.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- la classe décrit uniquement la famille : `nds-button`, `nds-icon-button`, `nds-input`, `nds-range`, `nds-select`, `nds-select-trigger`, `nds-time`, `nds-textarea`, `nds-inline-input`, `nds-switch`, `nds-radio-button-group`, `nds-checkbox`, `nds-quantity-stepper`, `nds-list-row`, `nds-large-icon-picker-trigger`, `nds-event-surface`, `nds-scrollbox` ;
|
||||||
|
- les patterns transverses utilisent aussi une classe de famille : `nds-panel`, `nds-card`, `nds-card-cover`, `nds-card-body`, `nds-card-actions`, `nds-tool-panel`, `nds-toolbar`, `nds-section-heading`, `nds-page-hero`, `nds-badge`, `nds-chip`, `nds-empty-state`, `nds-inline-notice`, `nds-progress`, `nds-filter-panel`, `nds-reorder-indicator`, `nds-modal`, `nds-modal-backdrop`, `nds-modal-footer`, `nds-drawer-panel`, `nds-drawer-resize-handle`, `nds-popover`, `nds-toast-stack`, `nds-toast` ;
|
||||||
|
- les variations passent par attributs : `data-size`, `data-variant`, `data-edge`, `data-border`, `data-axis` ;
|
||||||
|
- la page `/design-system` affiche aussi `data-state` comme axe de comparaison visuelle des états ;
|
||||||
|
- `data-size` accepte seulement les tailles prévues par la famille (`xs`, `sm`, `md`, `lg`, `xxs`, `micro` selon le contrôle) ; les checkboxes utilisent `md` 20 px, `sm` 18 px et `xs` 14 px ;
|
||||||
|
- `data-variant` couvre les intentions visuelles partagées : `primary`, `secondary`, `ghost`, `dashed`, `danger` et les variantes spécialisées documentées dans `/design-system`, dont `panel-toggle` pour les boutons icones accolés à un panneau, `icon-picker` pour les triggers de sélection compacts à icône, `interactive|tool|filter|modal` pour les surfaces, et `neutral|gold|success|warning|danger` pour les badges, notices et progress ;
|
||||||
|
- `data-edge="left|middle|right|top|center|bottom|top-left|top-right|bottom-right|bottom-left"` décrit la position d'un bouton dans un groupe ou contre un bord, et ajuste uniquement les coins ;
|
||||||
|
- `data-border="joined"` masque les bordures de jonction déduites depuis `data-edge` ;
|
||||||
|
- `data-border="none"` masque toutes les bordures sans changer la taille, le fond, le hover ou l'état actif ;
|
||||||
|
- `data-axis="horizontal|vertical"` reste réservé aux composants dont l'axe est métier, comme `nds-scrollbox` et `nds-reorder-indicator` ;
|
||||||
|
- les boutons icônes de réorganisation utilisent `nds-icon-button` avec `data-variant="secondary"` et une classe métier contenant `drag-handle` ; le curseur `grab/grabbing` est alors géré par le NDS ;
|
||||||
|
- les états fonctionnels restent portés par le HTML et l'application : `disabled`, `aria-pressed="true"`, `.active`, `.is-active`.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="nds-button" data-size="xs" data-variant="primary" data-edge="left" data-border="joined">Ajouter</button>
|
||||||
|
<button class="nds-icon-button" data-size="sm" data-variant="danger" aria-label="Supprimer"></button>
|
||||||
|
<button class="nds-icon-button" data-variant="panel-toggle" data-edge="right" data-border="joined" aria-pressed="true" aria-label="Pages"></button>
|
||||||
|
<select class="nds-select" data-size="sm"></select>
|
||||||
|
<button class="nds-select-trigger" data-size="sm" aria-pressed="false"></button>
|
||||||
|
<article class="nds-card" data-variant="interactive"><div class="nds-card-cover"></div><div class="nds-card-body"></div></article>
|
||||||
|
<section class="nds-panel"></section>
|
||||||
|
<span class="nds-badge" data-variant="gold">Rare</span>
|
||||||
|
<p class="nds-inline-notice" data-variant="danger">Erreur compacte</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
Les anciennes classes de variation `nds-*--*` ne doivent plus etre ajoutees. Les anciennes classes globales `.button`, `.primary`, `.danger`, `.ghost` et `.import-button` ne sont plus des APIs visuelles publiques : utiliser `nds-button` / `nds-icon-button` avec `data-variant`. Dans les outils, les classes métier peuvent rester pour le layout, la grille, les largeurs, le positionnement et les états fonctionnels. Le style visuel d'un contrôle générique doit venir de `website/src/styles/design-system/_controls.scss` ou de l'API publique `website/src/styles/design-system/_attributes.scss`.
|
||||||
|
|
||||||
|
### Surfaces Et Patterns
|
||||||
|
|
||||||
|
Les surfaces NDS couvrent les patterns transverses, pas les composants metier complets.
|
||||||
|
|
||||||
|
- `nds-panel` : panneau de lecture non repete, sans hover par defaut.
|
||||||
|
- `nds-card` : element repete ou ouvrable ; utiliser `nds-card-cover`, `nds-card-body` et `nds-card-actions` quand la structure cover/contenu/actions est necessaire.
|
||||||
|
- `nds-tool-panel` : surface de travail d'outil, plus discrete qu'une card, avec accent dore bas/droite.
|
||||||
|
- `nds-filter-panel` : filtres verticaux ou horizontaux via `data-variant="vertical|horizontal-compact|horizontal-menu"` ; sans attribut, le rendu vertical historique est conservé.
|
||||||
|
- `nds-empty-state` : etat vide court, avec action optionnelle.
|
||||||
|
- `nds-inline-notice` : feedback compact inline, avec `data-variant="info|success|warning|danger"`.
|
||||||
|
- `nds-progress` : rail de progression (taille `md`/défaut : 8 px, `sm` : 4 px, via `data-size`) ; le remplissage est le premier `span` enfant ou `.nds-progress-bar`.
|
||||||
|
- `nds-modal`, `nds-drawer-panel`, `nds-popover`, `nds-toast` : surfaces d'overlay. Le comportement React reste gere par les composants existants.
|
||||||
|
|
||||||
|
Les classes metier existantes peuvent coexister avec ces classes pendant la migration. Quand une classe metier reste necessaire, elle doit porter le layout, la grille ou le positionnement, pas recreer les fonds, bordures, hovers et focus partages.
|
||||||
|
|
||||||
|
## 4. Fond Nebula
|
||||||
|
|
||||||
|
Le fond global est defini dans `website/src/styles/_nebula.scss`.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- fond de page tres sombre avec nebuleuses violettes, indigo, rose et leger accent dore ;
|
||||||
|
- etoiles statiques, reparties de facon non alignee ;
|
||||||
|
- pas d'animation permanente de fond ;
|
||||||
|
- le contenu reste prioritaire sur l'ambiance.
|
||||||
|
|
||||||
|
Ne pas ajouter d'orbes decoratifs supplementaires. Si une section a besoin de profondeur, utiliser les fonds semi-transparents et les bordures existantes.
|
||||||
|
|
||||||
|
## 5. Typographie
|
||||||
|
|
||||||
|
Police principale :
|
||||||
|
|
||||||
|
```css
|
||||||
|
font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
```
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- pas de letter-spacing negatif ;
|
||||||
|
- corps de texte entre 14 et 16 px ;
|
||||||
|
- titres de cartes/modules entre 15 et 18 px ;
|
||||||
|
- titres de page avec `clamp` deja defini dans `_home.scss` ;
|
||||||
|
- paragraphs limites visuellement, pas de longs blocs sans respiration ;
|
||||||
|
- les `<i>` doivent etre en italique, pas utilises comme gris decoratif.
|
||||||
|
|
||||||
|
## 6. Heroes
|
||||||
|
|
||||||
|
Les heroes de la page d'accueil et des pages jeux utilisent le meme style via `.hero` et `.game-hero`.
|
||||||
|
|
||||||
|
Sur la page d'accueil, le hero conserve deux colonnes en fenêtre large. Jusqu'à `1024px`, le groupe des deux statistiques passe sous le texte et les actions, aligné à gauche. Les deux cartes restent côte à côte tant que leur largeur le permet.
|
||||||
|
|
||||||
|
L'action principale dépend des données locales : sans toolbox, « Créer ma première toolbox » ouvre directement la création guidée ; dès qu'une toolbox existe, « Voir mes toolboxes » mène à la liste. « Voir les jeux » reste l'action secondaire.
|
||||||
|
|
||||||
|
Caracteristiques :
|
||||||
|
|
||||||
|
- fond commun nebula sombre ;
|
||||||
|
- overlay sombre pour garder le texte lisible ;
|
||||||
|
- bordure gradient subtile dore/violet clair ;
|
||||||
|
- `border-radius: var(--radius-xl)` ;
|
||||||
|
- `box-shadow: var(--shadow-md)` ;
|
||||||
|
- les pages jeux peuvent fournir une image de hero via `--game-hero-image`.
|
||||||
|
|
||||||
|
Les pages jeux doivent heriter du hero commun. Une image de jeu peut enrichir le hero quand elle reste lisible avec l'overlay commun ; les cards et covers restent les emplacements prioritaires pour les images inspectables.
|
||||||
|
|
||||||
|
## 7. Cards
|
||||||
|
|
||||||
|
Les cards representent les elements repetes : toolboxes, jeux, categories, monstres, faune.
|
||||||
|
|
||||||
|
Style courant :
|
||||||
|
|
||||||
|
- fond sombre `rgba(16, 20, 38, 0.88)` ;
|
||||||
|
- bordure gradient fine, majoritairement doree avec une petite presence cyan ;
|
||||||
|
- support optionnel de `corner-shape: scoop` quand le navigateur le supporte ;
|
||||||
|
- pas de translation au hover pour les cards principales ;
|
||||||
|
- hover en violet/violet clair avec glow contenu.
|
||||||
|
|
||||||
|
La bordure haute/basse interne d'une cover doit rester doree quand elle separe une image du contenu.
|
||||||
|
|
||||||
|
Les surfaces de cards reutilisent les mixins `card-surface`, `card-surface-hover` et `card-cover-divider` depuis `website/src/styles/design-system/_surfaces.scss`.
|
||||||
|
|
||||||
|
Eviter les cards dans des cards. Les sections de page doivent rester des layouts ou panneaux, pas des empilements de cartes decoratives.
|
||||||
|
|
||||||
|
## 8. Modules Toolbox
|
||||||
|
|
||||||
|
Les modules sont des panneaux de travail, pas des cards classiques.
|
||||||
|
|
||||||
|
Style courant :
|
||||||
|
|
||||||
|
- fond semi-transparent `rgba(16, 20, 38, 0.42)` ;
|
||||||
|
- bordure neutre tres discrete ;
|
||||||
|
- accent dore leger en bas/droite ;
|
||||||
|
- hover violet/violet clair moins intense que les cards ;
|
||||||
|
- pas de mouvement au hover ;
|
||||||
|
- contenu interne au-dessus des pseudo-bordures via `z-index`.
|
||||||
|
- l'en-tête suit toujours l'ordre poignée de déplacement, icône, titre, switch de scroll éventuel, ajout éventuel, Configuration, suppression, chevron ; le titre peut se réduire mais les actions ne s'étirent pas ;
|
||||||
|
- le chevron de repli reste tout à droite ; l'état replié conserve l'en-tête et peut afficher le panneau Configuration sans révéler le contenu métier ;
|
||||||
|
- l'engrenage Configuration ouvre un panneau pleine largeur sous l'en-tête, nommé par `aria-controls` / `aria-expanded`. Il regroupe conditionnellement Espace, Guide, réglages propres à l'outil et Données (import/export) ; ouvrir l'ajout ferme Configuration et réciproquement ;
|
||||||
|
- dans Configuration, Espace et Données partagent la première ligne en deux colonnes. Espace affiche directement le sélecteur compact dont le placeholder décrit l'action. Les réglages propres à l'outil et la liaison Guide occupent toute la largeur en dessous ; si Espace est absent, Données utilise toute la première ligne ;
|
||||||
|
- le panneau Configuration reste une surface intégrée à l'outil : un seul contour discret, des sous-sections sans effet de carte imbriquée, séparées uniquement par des filets légers, avec libellés et contrôles compacts ;
|
||||||
|
- en fenêtre compacte, l'en-tête se répartit sur deux lignes et les sections/champs de Configuration passent en une colonne, dans l'ordre Espace, Données puis réglages spécifiques.
|
||||||
|
|
||||||
|
Le mot visible cote utilisateur est **outil**, pas **module**. Le terme module reste acceptable uniquement dans le code.
|
||||||
|
|
||||||
|
Les outils qui gèrent une collection affichent un message vide compact et discret tant
|
||||||
|
qu'aucun élément n'existe. Ce message réutilise le traitement visuel en pointillés du
|
||||||
|
planificateur de tâches, sans remplacer les états vides métier plus détaillés déjà
|
||||||
|
présents. Lorsqu'un outil vient d'être ajouté, la page défile doucement jusqu'à son
|
||||||
|
panneau afin qu'il reste immédiatement visible, y compris s'il a été inséré hors cadre.
|
||||||
|
|
||||||
|
### Outils En Deux Colonnes
|
||||||
|
|
||||||
|
Les outils avec controles a gauche et resultats a droite doivent reutiliser les classes generiques `tool-split-*`.
|
||||||
|
|
||||||
|
Exemples actuels :
|
||||||
|
|
||||||
|
- calculateur ;
|
||||||
|
- timer.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- ratio de base 35 / 65 entre controles et resultats ;
|
||||||
|
- lorsque son outil mesure au plus 820 px de large, le calculateur empile la saisie au-dessus de la liste, avec Calcul/Actuel à gauche et Résultat à droite dans la carte ; sous 560 px, cette carte revient à une seule colonne ;
|
||||||
|
- panneau gauche compact, sans hauteur artificielle inutile ;
|
||||||
|
- panneau droit scrollable uniquement quand l'utilisateur active le switch dedie ;
|
||||||
|
- hauteur scrollable cible : environ 450 px ;
|
||||||
|
- boutons reset, copie, scroll et tri alignes en haut a droite du panneau de resultats ;
|
||||||
|
- edition inline des libelles plutot que boutons ou alertes navigateur ;
|
||||||
|
- styles specifiques autorises uniquement pour la logique propre a l'outil.
|
||||||
|
|
||||||
|
### Champs Mixtes A Badges
|
||||||
|
|
||||||
|
Les champs texte qui remplacent une valeur brute par un badge visuel doivent utiliser le pattern `MixTagsInput`.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- le champ reprend le principe du mode mix Tagify : zone `contentEditable` et badges `contentEditable=false` ;
|
||||||
|
- la valeur sauvegardee reste une string normale, le badge est une representation atomique de cette valeur ;
|
||||||
|
- un badge est atomique : le curseur peut se placer avant ou apres, et la suppression retire toute la valeur associee ;
|
||||||
|
- la copie d'une selection contenant un badge doit copier la valeur brute, pas seulement le libelle visible.
|
||||||
|
- utiliser `size="small"` pour les champs compacts de 36 px, plutot que de deduire la taille depuis une classe metier.
|
||||||
|
|
||||||
|
### Timer
|
||||||
|
|
||||||
|
Le timer combine chronometre a etapes et comptes a rebours multiples.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- tabs compactes avec icones uniquement et tooltips ;
|
||||||
|
- chronometre avec centiemes visibles en plus petit dans le timer principal ;
|
||||||
|
- les temps sauvegardes gardent une taille uniforme ;
|
||||||
|
- compte a rebours sans centiemes ;
|
||||||
|
- types visibles : `Duree`, `Heure precise`, `Pattern horaire`, `Intervalle` ;
|
||||||
|
- les libelles au-dessus des champs `hh:mm:ss` restent accessibles en `sr-only`, mais ne sont pas visibles ;
|
||||||
|
- les boutons de controle utilisent les icones `play-circle`, `pause-circle`, `record-circle`, `stop-circle`, `refresh`, `sort-time`, `scrollable`.
|
||||||
|
- pour `Heure precise` et `Intervalle`, le refresh inline est un toggle compact de repetition automatique ; il est bloque si la frequence est inferieure a 5 minutes.
|
||||||
|
- le formulaire `Intervalle` affiche deux champs `hh:mm:ss` compacts : duree de repetition et depart optionnel ; un depart vide signifie `Maintenant`.
|
||||||
|
- les invalidations du formulaire countdown s'affichent en notice inline rouge compacte, portée par `useDraftForm`.
|
||||||
|
|
||||||
|
Alertes timer :
|
||||||
|
|
||||||
|
- switch a 3 etats par compte a rebours : aucune alerte, alerte si la toolbox est visible, alerte globale site ;
|
||||||
|
- le garde-fou ne se base pas sur le temps restant, mais sur la frequence configuree ;
|
||||||
|
- le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ;
|
||||||
|
- un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures.
|
||||||
|
|
||||||
|
### Calendrier
|
||||||
|
|
||||||
|
Le calendrier est un outil de planification visuelle, distinct du timer.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- l'outil propose une grille hebdomadaire jours x creneaux de 30 minutes ou une grille mensuelle datée avec navigation par mois ;
|
||||||
|
- les 7 jours de la semaine doivent rester visibles sans scroll horizontal ; seul le scroll vertical sert a parcourir les heures ;
|
||||||
|
- le formulaire compact est masque par defaut ; il s'ouvre via le bouton `+` du module ou par clic sur une entree ;
|
||||||
|
- le bouton annuler ferme le formulaire, et desactiver le bouton `+` le ferme aussi quand il sert a creer une entree ;
|
||||||
|
- l'ajout manuel et la selection de cellules alimentent le meme formulaire compact quand il est ouvert ;
|
||||||
|
- les champs horaires hebdomadaires restent courts, acceptent un pas de 5 minutes, et les alertes d'echeance utilisent des checkboxes `10 min`, `5 min` et `Au debut et pendant` ;
|
||||||
|
- les evenements sont affiches comme des blocs colores dans la grille, sans boutons internes et sans dominer la palette globale ;
|
||||||
|
- quand deux evenements se chevauchent sur un meme jour, ils se partagent la largeur de la colonne ; la V1 gere visuellement jusqu'a deux evenements simultanes ;
|
||||||
|
- la suppression d'une entree se fait dans le formulaire d'edition ;
|
||||||
|
- la copie et le collage d'entree utilisent les raccourcis clavier `Ctrl+C` et `Ctrl+V` ;
|
||||||
|
- le mini-switch repetable est actif par defaut ; une entree non repetable est supprimee quand son echeance de la semaine courante est terminee ;
|
||||||
|
- les alertes reutilisent le switch a 3 etats du timer : aucune, toolbox visible, globale site ;
|
||||||
|
- le choix de couleur reprend le pattern de palette compacte du Bloc Note ;
|
||||||
|
- le choix initial s'affiche dans l'outil lui-meme ; la vue mensuelle affiche le nom du mois et des boutons mois precedent / courant / suivant ;
|
||||||
|
- en vue mensuelle, la règle mensuelle peut cibler le numero du jour ou le rang du jour de semaine via un switch 2 états compact, indépendamment du choix de répétition.
|
||||||
|
- en vue mensuelle, les dates de debut et de fin utilisent un champ compact qui ouvre un mini-calendrier avec navigation ;
|
||||||
|
- en vue mensuelle, le formulaire distingue date de debut et date de fin ; une plage d'un seul jour utilise la meme valeur pour les deux ;
|
||||||
|
- en vue mensuelle, la répétition propose `Aucune`, `Chaque semaine`, `Chaque mois`; `Chaque semaine` est indisponible pour la règle `Numéro du jour` ;
|
||||||
|
- en vue mensuelle, la suppression d'une entree repetee distingue suppression de l'occurrence affichee et suppression de toute la serie ;
|
||||||
|
- en vue mensuelle, un clic-glisse sur plusieurs cellules preconfigure la plage de jours du formulaire ;
|
||||||
|
- en vue mensuelle, les champs horaires ne sont pas affiches ; les evenements couvrent des journees completes ;
|
||||||
|
- en vue mensuelle, les evenements multi-jours s'affichent comme une barre horizontale et ne sont pas dupliques dans chaque cellule ;
|
||||||
|
- les vues hebdomadaire et mensuelle affichent un indicateur discret du moment actuel.
|
||||||
|
- en vue hebdomadaire, un switch standard peut masquer les longues plages horaires vides ; la tranche masquée reste indiquée dans la grille, pas dans un texte qui déborde de la colonne horaire.
|
||||||
|
- le calendrier supporte l'import/export texte via la section Données du panneau Configuration, pour les vues hebdomadaire et mensuelle.
|
||||||
|
|
||||||
|
### Arbre D'objectifs
|
||||||
|
|
||||||
|
L'arbre d'objectifs représente des chaînes de craft, de farm ou de prérequis sous forme d'arborescence dépliable.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- utiliser le ratio `tool-split-*` 35 / 65 quand l'édition est visible ;
|
||||||
|
- le panneau gauche édite uniquement le nœud sélectionné via les onglets `Base`, `Contenu` quand le type le nécessite, et `Avancé` ;
|
||||||
|
- le panneau gauche ne doit pas porter les boutons d'ajout racine ou sous-étape ; l'ajout racine se fait via un bouton dans le schéma, long en haut en mode vertical et haut/étroit à gauche en mode horizontal, et les sous-étapes via le bouton `+` des cards ;
|
||||||
|
- l'onglet `Base` contient le type `Étape`, `Texte`, `Calculateur` ou `Checklist`, puis le titre ; pour une étape, il expose aussi l'icône et la couleur d'icône ;
|
||||||
|
- l'onglet `Contenu` est réservé aux types `Texte`, `Calculateur` et `Checklist` ; les étapes ne doivent pas afficher cet onglet ;
|
||||||
|
- l'onglet `Avancé` contient le reset par nœud ; pour une étape seulement, il ajoute les sections `Quantité` et `Progression`, avec un switch où l'état actif correspond à la progression automatique par les enfants ;
|
||||||
|
- le switch de progression expose une aide courte sous le contrôle afin de différencier calcul automatique et mise à jour manuelle ;
|
||||||
|
- une étape dont tous les enfants directs ont une cible de `1` se comporte par défaut en mise à jour manuelle, sauf choix explicite du mode automatique ;
|
||||||
|
- le panneau droit reste la source visuelle principale, avec branches compactes, liaisons SVG mesurées et barres de progression ;
|
||||||
|
- le panneau droit expose un switch avec icône `rotate` pour alterner entre organisation horizontale et verticale ; l'orientation est persistée par outil ;
|
||||||
|
- les liaisons entre nœuds doivent être dessinées par une couche dédiée basée sur les positions réelles des cartes, pas par des pseudo-éléments CSS dépendants de largeurs fixes ;
|
||||||
|
- une card peut avoir plusieurs prérequis visibles : elle garde un parent principal pour le placement, mais des liaisons supplémentaires peuvent converger vers elle ;
|
||||||
|
- tous les enfants restent rendus comme des nœuds d'organigramme, même sans sous-enfant, afin que l'ajout ultérieur d'un enfant ne change pas brutalement la structure ;
|
||||||
|
- l'ouverture d'une branche doit replier les branches sœurs du même niveau et leurs descendants, afin de limiter la largeur déployée en mode deux colonnes ;
|
||||||
|
- quand une branche ouverte contient un objectif à plusieurs parents, les branches nécessaires aux autres parents visibles doivent rester ouvertes afin que la convergence reste compréhensible ;
|
||||||
|
- les nœuds affichent une icône principale et révèlent leur titre via `Tooltip`, afin de garder une lecture proche organigramme ;
|
||||||
|
- les types `Texte`, `Calculateur` et `Checklist` sont des mini-contenus internes au nœud : leurs lignes doivent être visibles directement dans la carte, avec une zone d'ajout compacte ;
|
||||||
|
- les cards de liste doivent laisser leur preview interne occuper la largeur disponible de la card, sans largeur fixe plus étroite que la carte ;
|
||||||
|
- ces mini-contenus ne remplacent pas les sous-objectifs : les sous-objectifs restent des enfants d'organigramme séparés ;
|
||||||
|
- les lignes `Checklist` suivent le comportement de l'outil checklist : cible `1` affiche une checkbox ; cible supérieure à `1` affiche une quantité avec contrôles `-` et `+` ;
|
||||||
|
- les lignes `Calculateur` exposent une formule et un résultat ; la variable `base` référence la cible calculée du parent direct ;
|
||||||
|
- les cards peuvent exposer des actions secondaires au hover/focus, notamment modification et suppression, sans les rendre visibles au repos ;
|
||||||
|
- le bouton `+` d'une card ajoute toujours une sous-étape ; les nœuds `Texte`, `Calculateur` et `Checklist` exposent un bouton long dédié sous leur preview pour ajouter une entrée interne ;
|
||||||
|
- le bouton long d'entrée interne doit avoir une tooltip explicite, car son libellé visible reste volontairement compact ;
|
||||||
|
- l'accent de couleur d'un nœud doit s'appliquer à toute la card, y compris au hover, sans bordure latérale d'une couleur différente ;
|
||||||
|
- la card sélectionnée en mode édition doit être clairement identifiable par un contour et un halo plus présents que l'état sélectionné en lecture ;
|
||||||
|
- le drag & drop des nœuds doit afficher son indicateur selon l'orientation de la liste : gauche/droite en horizontal, haut/bas en vertical ;
|
||||||
|
- les actions de nœud utilisent des boutons icônes : ajout, repli, suppression, déplacement ;
|
||||||
|
- le bouton de déplacement doit rester clairement visible sur les cards compactes, notamment les nœuds `Texte` et `Checklist` ;
|
||||||
|
- la progression d'un parent doit rester agrégée et lisible sans afficher une formule détaillée dans la carte ;
|
||||||
|
- en panneau latéral ou fenêtre compacte, l'arbre passe en une colonne sans scroll horizontal.
|
||||||
|
|
||||||
|
### Combos
|
||||||
|
|
||||||
|
L'outil Combos affiche des inputs de manettes et clavier sous forme de tokens visuels compacts.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- les touches simples sont rendues en HTML/CSS, pas en fichiers SVG distincts ;
|
||||||
|
- les formes specifiques peuvent utiliser de petits SVG inline ou des formes CSS si le HTML seul devient ambigu ;
|
||||||
|
- les couleurs de boutons restent des accents localises et ne doivent pas dominer le panneau ;
|
||||||
|
- les sequences longues doivent revenir a la ligne sans agrandir la largeur de l'outil ;
|
||||||
|
- l'edition utilise une palette guidee pour les manettes et une palette avec saisie courte pour clavier/souris ;
|
||||||
|
- l'ajout par clic cree des etapes successives par defaut, sans bouton dedie de nouvelle etape ;
|
||||||
|
- le clavier utilise AZERTY par defaut et propose un switch QWERTY ;
|
||||||
|
- le mode simultane doit etre volontaire, via switch ou depot d'une touche dans une etape existante ;
|
||||||
|
- les categories sont regroupees automatiquement depuis le champ categorie du combo.
|
||||||
|
|
||||||
|
### Bloc Note Riche
|
||||||
|
|
||||||
|
Le Bloc Note utilise une edition directe en `contentEditable`, sans mode preview separe.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- pages multiples accessibles par un bouton icone `pages` pose en absolute sur la bordure gauche de l'outil ;
|
||||||
|
- le bouton ouvre toujours un menu deroulant de pages, y compris en page toolbox complete, avec deroulement de gauche vers la droite ;
|
||||||
|
- le renommage d'une page passe par un bouton edition qui active l'edition inline du titre ;
|
||||||
|
- toolbar compacte en haut de l'outil ;
|
||||||
|
- boutons courts pour gras, italique, souligne, barre, titre et listes ;
|
||||||
|
- le paragraphe est le style par defaut et n'a pas de bouton dedie ;
|
||||||
|
- les types de listes sont regroupes dans un dropdown compact ;
|
||||||
|
- couleurs texte et surlignage regroupees dans un dropdown unique avec icone arc-en-ciel et libelles de categories ;
|
||||||
|
- date de derniere modification affichee en pied d'outil, sans prendre le dessus sur le contenu ;
|
||||||
|
- switch de stockage des dessins en pied d'outil, avec icone sauvegarde et etat actif ;
|
||||||
|
- placeholder integre dans la surface d'edition quand la note est vide ;
|
||||||
|
- le HTML persiste doit rester nettoye via allowlist, jamais via HTML libre.
|
||||||
|
|
||||||
|
### Mémos
|
||||||
|
|
||||||
|
L'outil Mémos sert aux informations courtes type post-it.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- cartes compactes, non imbriquees, avec accent couleur localise ;
|
||||||
|
- edition directe du texte court ;
|
||||||
|
- zone de texte en hauteur automatique, sans resize manuel ni scrollbar interne ;
|
||||||
|
- palette de couleurs compacte reprenant les swatches du Bloc Note ;
|
||||||
|
- la creation de mémo est masquee par defaut et s'ouvre via le bouton `+` du header d'outil ;
|
||||||
|
- réorganisation des cartes par poignée de drag & drop compacte ;
|
||||||
|
- bande deroulante visible uniquement quand elle est active et qu'au moins un mémo ou une alerte de planificateur existe ; elle affiche tous les mémos dans l'outil, peut intégrer les tâches non effectuées proches du reset depuis un ou tous les planificateurs de la même toolbox, et reste fixée en bas des pages complètes de cette toolbox ainsi que des pages du jeu qui lui est associé ;
|
||||||
|
- la bande fixe laisse le contenu verticalement accessible et suit la largeur disponible entre la sidebar et le bord droit.
|
||||||
|
|
||||||
|
### Calque De Dessin
|
||||||
|
|
||||||
|
Les annotations dessinees utilisent un canvas transparent superpose au contenu annote.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- le calque est inactif par defaut pour laisser le contenu editable ;
|
||||||
|
- en mode dessin, les interactions pointer sont capturees par le canvas ;
|
||||||
|
- les controles de dessin sont regroupes dans un panneau reutilisable vertical, colle a droite de la surface annotee ;
|
||||||
|
- seuls les controles directement lies au dessin sont affiches quand le mode dessin est actif ;
|
||||||
|
- les controles restent compacts : gommer, annuler, effacer, couleur et epaisseur via dropdowns ;
|
||||||
|
- le mode temporaire privilegie le griffonnage de session et utilise `sessionStorage` ;
|
||||||
|
- le mode permanent suit le stockage IndexedDB de l'outil ;
|
||||||
|
- pour les surfaces scrollables, le canvas suit le contenu complet plutot que le viewport visible.
|
||||||
|
- sur les annotations d'images, les traits utilisent des coordonnees en pourcentage et restent visibles dans l'aperçu reduit ; l'edition se fait uniquement en plein ecran et le switch de sauvegarde reste disponible dans la barre de dessin.
|
||||||
|
|
||||||
|
## 8. Tabs Et Switchs
|
||||||
|
|
||||||
|
### Dropdowns Compacts
|
||||||
|
|
||||||
|
Les menus flottants compacts d'icones, couleurs, epaisseurs ou actions rapides doivent reutiliser `CompactDropdown`.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- conserver un trigger compact, idealement icone seule avec tooltip ;
|
||||||
|
- fermer au clic exterieur, a `Escape`, a la selection et quand le focus sort du menu ;
|
||||||
|
- garder le contenu interne libre pour supporter grilles d'icones, swatches, libelles de categories ou choix d'epaisseur ;
|
||||||
|
- reutiliser les classes visuelles existantes du contexte quand le menu appartient a un outil precis.
|
||||||
|
|
||||||
|
Tabs :
|
||||||
|
|
||||||
|
- utiliser le composant commun quand un outil doit alterner entre plusieurs modes ;
|
||||||
|
- icones seules quand les labels sont evidents ou fournis en tooltip ;
|
||||||
|
- etat actif sobre : bordure doree, fond legerement dore/violet, pas de gros glow.
|
||||||
|
|
||||||
|
Switchs :
|
||||||
|
|
||||||
|
- les switchs binaires utilisent l'icone a gauche et le rail a droite quand ils controlent un mode technique comme `scrollable` ;
|
||||||
|
- les switchs a plusieurs etats doivent rester compacts, avec icones et tooltips ;
|
||||||
|
- les etats desactives doivent etre clairement attenues et sans etat actif dore.
|
||||||
|
|
||||||
|
## 9. Panneaux De Section
|
||||||
|
|
||||||
|
Les grandes sections non repetitives, comme le dialogue d'origine et les sections de presentation, utilisent `.nebula-panel`.
|
||||||
|
|
||||||
|
Style courant :
|
||||||
|
|
||||||
|
- fond semi-transparent `rgba(16, 20, 38, 0.42)` ;
|
||||||
|
- bordure statique identique aux outils en etat normal ;
|
||||||
|
- accent dore leger en bas/droite ;
|
||||||
|
- pas de hover sur les panneaux de lecture ;
|
||||||
|
- implementation via le mixin `gold-panel-frame` dans `website/src/styles/_mixins.scss`.
|
||||||
|
|
||||||
|
## 10. Catalogue Documentation
|
||||||
|
|
||||||
|
La page catalogue des outils utilise un layout de documentation.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- hero court, puis contenu principal en deux colonnes sur desktop ;
|
||||||
|
- sommaire a gauche en panneau sticky, avec categories puis liens d'outils de la categorie active ;
|
||||||
|
- une seule categorie d'usage est affichee a droite a la fois ;
|
||||||
|
- la categorie active commence par un mini-hero descriptif, sans compteur decoratif ;
|
||||||
|
- chaque fiche commence par le titre, l'icone, le badge de categorie et une description directe de l'outil ;
|
||||||
|
- l'exemple interactif reste visible en permanence et centre horizontalement ;
|
||||||
|
- les reperes de boutons propres a l'outil sont places a gauche de l'exemple, puis empiles au-dessus dans la disposition compacte jusqu'a `1024px` ;
|
||||||
|
- les reperes sont groupes par categorie de controles de l'outil, avec apercu visuel du bouton et label court ;
|
||||||
|
- les descriptions de reperes sont reservees aux comportements non evidents, par exemple un bouton qui agit sur toute la ligne ou une action de drag & drop ;
|
||||||
|
- les fonctionnalites detaillees et formats d'import texte sont presentes sous l'exemple dans un panneau repliable pour ne pas alourdir la lecture avant manipulation ;
|
||||||
|
- les controles propres au catalogue, comme la reinitialisation d'une demo, ne sont pas documentes comme fonctionnalites de l'outil ;
|
||||||
|
- en fenêtre compacte jusqu'a `1024px`, le sommaire devient un bloc normal place avant les fiches, sans position sticky ;
|
||||||
|
- sur l'accueil, cette même disposition masque l'illustration de la section « Origine du nom » et sa séparation verticale pour privilégier le dialogue.
|
||||||
|
|
||||||
|
## 11. Boutons
|
||||||
|
|
||||||
|
### Selects Des Outils
|
||||||
|
|
||||||
|
Tous les `<select>` rendus dans le contenu d'un outil doivent utiliser le style factorise par `website/src/styles/design-system/_controls.scss`.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- flèche custom violet clair, doree au hover/focus ;
|
||||||
|
- fond sombre en couches legerement violet/indigo ;
|
||||||
|
- hauteur compacte cible 36 px ;
|
||||||
|
- options sur fond `#11182f` ;
|
||||||
|
- les fichiers d'outil peuvent definir uniquement les contraintes de layout necessaires : largeur, grille, flex, ou typographie locale.
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
|
||||||
|
Les boutons primary sont plus contrastes que les boutons standards.
|
||||||
|
|
||||||
|
Style :
|
||||||
|
|
||||||
|
- fond violet/indigo sombre ;
|
||||||
|
- bordure doree ;
|
||||||
|
- hover plus lumineux avec bordure doree plus nette ;
|
||||||
|
- glow violet/dore contenu ;
|
||||||
|
- pas de `brightness()` global ;
|
||||||
|
- pas de translation verticale.
|
||||||
|
|
||||||
|
### Boutons standards
|
||||||
|
|
||||||
|
Hover :
|
||||||
|
|
||||||
|
- fond nebula plus lumineux ;
|
||||||
|
- bordure violet clair ;
|
||||||
|
- glow violet tres discret ;
|
||||||
|
- pas d'accent dore systematique.
|
||||||
|
|
||||||
|
### Boutons Toggle Temporaires
|
||||||
|
|
||||||
|
Les boutons qui ouvrent temporairement un panneau, un menu ou un mode local ne doivent pas utiliser le style switch.
|
||||||
|
|
||||||
|
Etat actif :
|
||||||
|
|
||||||
|
- bordure gradient dore/violet plus visible que le hover ;
|
||||||
|
- fond sombre avec degrade transparent/dore tres leger et touche violette en bas a droite, sans remplissage primary ;
|
||||||
|
- texte ou icone clair pour préserver la lisibilite ;
|
||||||
|
- trait interne dore discret pour distinguer l'etat actif des badges et actions primary ;
|
||||||
|
- bordure dore/dore clair statique, sans effet toast ;
|
||||||
|
- pas de glow externe fort ni animation permanente ;
|
||||||
|
- utiliser le mixin `temporary-toggle-active` pour garder les variantes coherentes.
|
||||||
|
|
||||||
|
### Ghost
|
||||||
|
|
||||||
|
Hover minimal :
|
||||||
|
|
||||||
|
- leger fond violet transparent ;
|
||||||
|
- texte plus clair ;
|
||||||
|
- pas de glow.
|
||||||
|
|
||||||
|
### Danger
|
||||||
|
|
||||||
|
Reserve aux actions destructrices.
|
||||||
|
|
||||||
|
- couleur rouge au repos ;
|
||||||
|
- hover rouge sombre, bordure rouge, texte/icones blancs ;
|
||||||
|
- confirmation en modale pour les actions irreversibles.
|
||||||
|
|
||||||
|
### Boutons icones
|
||||||
|
|
||||||
|
Les icones utilisent `currentColor` via `.ui-icon`.
|
||||||
|
|
||||||
|
- taille habituelle : 20 px ;
|
||||||
|
- bouton : 40 x 40 px ;
|
||||||
|
- hover neutre : icone violet clair `#c4b5fd` + leger glow ;
|
||||||
|
- hover danger : icone blanche + glow rouge discret ;
|
||||||
|
- utiliser les SVG de `website/public/static/icons`.
|
||||||
|
|
||||||
|
## 11. Formulaires Et Checkbox
|
||||||
|
|
||||||
|
Inputs et textareas :
|
||||||
|
|
||||||
|
- fond sombre ;
|
||||||
|
- bordure fine ;
|
||||||
|
- focus dore via `:focus-visible` ;
|
||||||
|
- placeholders en `--color-text-muted`.
|
||||||
|
|
||||||
|
Checkbox :
|
||||||
|
|
||||||
|
- style custom global ;
|
||||||
|
- format carre strict ;
|
||||||
|
- fond non coche legerement eclaire ;
|
||||||
|
- bordure dore/violet ;
|
||||||
|
- etat coche avec gradient dore/violet et coche blanche ;
|
||||||
|
- spacing adapte dans les filtres.
|
||||||
|
|
||||||
|
Accessibilite :
|
||||||
|
|
||||||
|
- utiliser `.sr-only` pour conserver un label utile sans l'afficher ;
|
||||||
|
- ne pas retirer les labels accessibles au profit du visuel uniquement.
|
||||||
|
|
||||||
|
## 12. Navigation
|
||||||
|
|
||||||
|
Sidebar :
|
||||||
|
|
||||||
|
- fond sombre, stable ;
|
||||||
|
- icones SVG pour Accueil, Toolboxes et Jeux ;
|
||||||
|
- navigation active en violet soft ;
|
||||||
|
- le logo controller garde le gradient de l'ancien badge et une rotation au hover.
|
||||||
|
- `sidebar[data-variant="compact"]` réduit la navigation à 72 px jusqu'à `1024px`, ainsi que lorsqu'un panneau toolbox latéral est ouvert en fenêtre large : la manette conserve sa rotation au hover, les trois entrées principales et les trois raccourcis secondaires utilisent `Tooltip`, puis l’encart inférieur contient uniquement les actions globales exporter/importer empilées avec leurs propres infobulles.
|
||||||
|
|
||||||
|
Le nom de marque affiche `Sokko` en blanc et `G` en dore. Au hover, `Sokko` anime vers le dore de droite a gauche puis revient au hover out.
|
||||||
|
|
||||||
|
## 13. Filtres Et Tableaux
|
||||||
|
|
||||||
|
Filtres :
|
||||||
|
|
||||||
|
- `vertical` conserve le panneau latéral historique avec des chips checkbox, icône puis texte ;
|
||||||
|
- `horizontal-compact` aligne recherche, petit groupe d’options directes, correspondance et reset au-dessus des résultats ;
|
||||||
|
- `horizontal-menu` remplace les groupes nombreux, ambigus ou avancés par des `nds-select-trigger` ouvrant des `nds-popover` ;
|
||||||
|
- `nds-filter-menu[data-columns="2"]` répartit les options aux libellés courts sur deux colonnes dans la largeur standard du popover, afin de compacter chaque option ;
|
||||||
|
- les menus multi-sélection gardent les libellés visibles et affichent le nombre de critères actifs dans leur trigger ;
|
||||||
|
- les listes des popovers de filtres prennent leur hauteur naturelle et n’ajoutent pas de défilement interne ;
|
||||||
|
- un groupe contenant un menu ouvert passe au-dessus de la section des badges actifs pour que celle-ci n’intercepte pas les clics dans le menu ;
|
||||||
|
- les sélecteurs de condition comme `ET / OU` ou `≥ / =` restent dans une petite surface bordée afin de les distinguer des options du menu ;
|
||||||
|
- lorsque la condition ne concerne qu’un groupe avancé, comme les catégories Diablo IV, son sélecteur est placé dans le popover correspondant avec 12 px d’espace avant les options ;
|
||||||
|
- toute option réduite à son icône doit utiliser le composant commun `Tooltip` et conserver un nom accessible (`aria-label`) ; l’attribut HTML `title` ne remplace pas le tooltip ;
|
||||||
|
- les options directes avec libellé suivent la largeur naturelle de leur contenu ; lorsqu’elles passent en icône seule, elles retrouvent une largeur compacte commune ;
|
||||||
|
- la recherche des barres horizontales est plafonnée à `260px` afin de ne pas étirer inutilement les filtres ;
|
||||||
|
- `nds-active-filters` forme une seconde section compacte dans la barre principale, séparée sur toute la largeur par un trait doré semi-transparent sans ombre ; la section haute conserve les 10 px de padding vertical de la barre, tandis que la section basse se limite à 4 px ; elle affiche un badge fermable par recherche ou critère actif, dont le bouton retire uniquement le filtre correspondant ;
|
||||||
|
- les barres sont sticky sous la topbar en fenêtre large, reviennent à la ligne et redeviennent statiques en fenêtre compacte ;
|
||||||
|
- reset avec bouton icône `rubber`.
|
||||||
|
|
||||||
|
Tableaux :
|
||||||
|
|
||||||
|
- pas de scroll horizontal pour les tableaux de degats MHWilds ;
|
||||||
|
- cellules compactes ;
|
||||||
|
- bordures fines ;
|
||||||
|
- lisibilite prioritaire sur la decoration.
|
||||||
|
|
||||||
|
Outil Tableau :
|
||||||
|
|
||||||
|
- scroll horizontal autorise uniquement dans la surface de grille editable ;
|
||||||
|
- entetes de lignes/colonnes compacts et stables ;
|
||||||
|
- intitulés de lignes/colonnes éditables sans modifier les références de formule ;
|
||||||
|
- cellules en inputs directs, avec focus dore et erreurs de formule discrètes ;
|
||||||
|
- sélection multiple visible par fond cyan/violet discret et destinée aux actions de plage comme la copie TSV ;
|
||||||
|
- actions de lignes/colonnes en boutons icones.
|
||||||
|
|
||||||
|
## 14. Toolboxes
|
||||||
|
|
||||||
|
### Espaces d'outils
|
||||||
|
|
||||||
|
- La barre d'espaces se place entre le hero de la toolbox et les contrôles de disposition, dans la page complète comme dans le drawer lié à un jeu.
|
||||||
|
- Quand seul l'espace par défaut existe, son onglet est masqué et le bouton `Nouvel espace` reste aligné à droite. À partir de deux espaces, la liste devient un `tablist` horizontal scrollable et l'ajout prend la forme d'un bouton `+` placé immédiatement après le dernier onglet.
|
||||||
|
- L'espace actif reprend les surfaces et focus des radio-button groups. Les onglets ciblés par un outil affichent un soulignement doré ; les espaces secondaires disposent d'une poignée de réordonnancement distincte avec un retrait à gauche.
|
||||||
|
- Le réordonnancement des onglets reprend l'indicateur d'insertion doré des outils, positionné à gauche ou à droite selon la destination. Les actions de renommage et suppression restent groupées à droite et passent sous les onglets en fenêtre étroite.
|
||||||
|
|
||||||
|
Les toolboxes sont locales et stockees dans le navigateur.
|
||||||
|
|
||||||
|
La fenêtre « Ajouter un outil » peut s'étendre jusqu'à `900px` et présente les outils sur 4 colonnes à `768px`, puis 5 à partir de `900px`. Chaque carte compacte place l'icône à gauche du nom complet, puis une courte phrase d'usage issue de `site.json` sur toute la largeur. Le contenu est réparti dans les cinq catégories du catalogue, chacune signalée par un titre et un séparateur discret, sans onglets de filtrage. Lorsque la hauteur disponible ne suffit pas, la modale utilise directement `scrollbar-control`, la scrollbar verticale du NDS. L'ajout rapide reste une grille d'icônes sans description ; ses boutons utilisent les infobulles NDS au survol et au focus.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- pas de champ description ;
|
||||||
|
- image de jeu si associee a un jeu ;
|
||||||
|
- icone editable si toolbox libre ;
|
||||||
|
- action importer/exporter clairement identifiee ;
|
||||||
|
- budget IndexedDB recommande visible en bas de page/panneau, avec quota navigateur reel seulement informatif ;
|
||||||
|
- import/export global disponibles.
|
||||||
|
|
||||||
|
Les cards toolbox doivent garder une taille compacte. L'image/icone de cover est cliquable.
|
||||||
|
|
||||||
|
## 15. Donnees Jeux
|
||||||
|
|
||||||
|
Le contenu editorial editable doit rester dans :
|
||||||
|
|
||||||
|
- `website/public/data/site.json` pour les textes generaux ;
|
||||||
|
- `website/public/data/games.json` pour la liste des jeux ;
|
||||||
|
- `website/public/data/mhwilds/*.json` pour les donnees MHWilds.
|
||||||
|
|
||||||
|
Les retours a la ligne `\n` du JSON doivent etre rendus correctement. Le JSX ne doit pas contenir de contenu editorial long si une entree JSON existe deja.
|
||||||
|
|
||||||
|
## 16. Modales, Drawer Et Notifications
|
||||||
|
|
||||||
|
Modales :
|
||||||
|
|
||||||
|
- fond floute mais pas trop opaque ;
|
||||||
|
- panneau centre ;
|
||||||
|
- fermeture au clic exterieur quand l'action le permet ;
|
||||||
|
- boutons icones pour fermer/zoomer quand approprie.
|
||||||
|
- la visionneuse d'un outil Images peut recevoir la collection du bloc courant : sa galerie utilise de grandes cards commutables entre `4:3` et `1:1`, avec image non recadrée et libellé toujours visible, dans un flux responsive centré et scrollable uniquement verticalement ; un bouton texte affiche le ratio actif, tandis que les boutons `−` et `+` règlent leur largeur de 180 à 420 px par paliers de 40 px ; le ratio et la largeur sont mémorisés comme préférences locales ; les gouttières entre cards restent compactes tandis que le bord de la galerie conserve un padding plus généreux ;
|
||||||
|
- la vue detaillee expose la position, un retour explicite a la galerie et des boutons precedent/suivant bloques aux extremites ; les fleches gauche/droite reprennent cette navigation ;
|
||||||
|
- une visionneuse d'image seule, notamment pour les annotations, ne doit afficher ni galerie ni navigation de collection.
|
||||||
|
|
||||||
|
Drawer toolbox :
|
||||||
|
|
||||||
|
- le bouton de topbar associe icone et texte visible : « Afficher la toolbox » quand le drawer est ferme, puis « Masquer la toolbox » lorsqu'il est ouvert ;
|
||||||
|
- à partir de `1440px`, le panneau lateral est ancre a droite, ouvert a `700px` et redimensionnable uniquement vers une largeur superieure ;
|
||||||
|
- à partir de `1440px`, le shell reserve toujours `700px` au panneau : toute largeur supplementaire recouvre la partie droite de la page sans la reduire davantage ;
|
||||||
|
- la page reste visible et interactive, sans backdrop bloquant ni fermeture au clic exterieur ;
|
||||||
|
- sous `1440px`, la topbar propose une bascule « Guide / Toolbox » à la taille standard des groupes de boutons ; chaque vue occupe toute la zone de contenu et la poignée de redimensionnement est masquée ;
|
||||||
|
- le guide et la toolbox restent montés après leur première ouverture en mode compact afin de conserver filtres, saisies et positions de défilement ; la vue masquée est retirée du parcours clavier et de l'arbre d'accessibilité ;
|
||||||
|
- la sidebar mesure `72px` jusqu'à `1024px`, puis `248px` jusqu'au passage en panneau latéral ;
|
||||||
|
- la largeur revient a `700px` a chaque ouverture et n'est pas persistee ;
|
||||||
|
- les actions internes ne doivent pas fermer le drawer sauf action explicite.
|
||||||
|
|
||||||
|
La cible responsive est une fenêtre d'ordinateur de second écran, en plein écran ou en demi-écran. Les parcours sont validés à partir de `768px`. Il ne faut pas concevoir de navigation, de composant ni de parcours dédié au téléphone. Sous la largeur validée, les règles servent uniquement à conserver l'accès au contenu et à empêcher un débordement horizontal global, sans garantie de confort.
|
||||||
|
|
||||||
|
Quand la zone utile d'une page toolbox passe sous `900px`, les outils sont forcés sur une seule colonne et le sélecteur une/deux colonnes est masqué. La préférence de disposition reste enregistrée et redevient applicable lorsque la largeur le permet.
|
||||||
|
|
||||||
|
Notifications internes :
|
||||||
|
|
||||||
|
- pas d'alerte navigateur ;
|
||||||
|
- utiliser les toasts internes globaux ;
|
||||||
|
- position configurable gauche/droite via le switch de la page toolboxes ;
|
||||||
|
- une nouvelle alerte s'empile au-dessus des precedentes ;
|
||||||
|
- limite visible : 5 toasts ;
|
||||||
|
- a la 6e alerte, fermer la plus ancienne ;
|
||||||
|
- durée par défaut des confirmations ordinaires : 6 secondes ; une durée explicite reste prioritaire avec un minimum d'une seconde ;
|
||||||
|
- les alertes Timer, Calendrier et le rappel de sauvegarde sont persistants jusqu'à leur fermeture manuelle ;
|
||||||
|
- style plus visible que les confirmations simples : bordure conique animee, pulse leger, arrivee depuis le cote configure.
|
||||||
|
|
||||||
|
## 17. Controles Interactifs
|
||||||
|
|
||||||
|
Les champs, selects, boutons icones, switchs et choix compacts utilisent une base commune issue de `website/src/styles/design-system/_controls.scss`. Les fichiers d'outils peuvent choisir une taille ou poser une contrainte de layout, mais ne doivent pas redefinir localement les fonds, bordures, hovers ou etats `primary`/`danger`.
|
||||||
|
|
||||||
|
Inputs et textareas :
|
||||||
|
|
||||||
|
- utiliser `field-control` pour les champs standards ;
|
||||||
|
- la base globale cible uniquement les types texte explicites : `text`, `search`, `email`, `url`, `password` et `textarea` ;
|
||||||
|
- ne pas utiliser de selecteur global negatif comme `input:not([type="checkbox"])`, afin de ne pas capter les champs `number`, `time`, `file` ou les cellules specialisees ;
|
||||||
|
- tout nouvel `<input>` texte doit declarer son `type` explicitement ;
|
||||||
|
- utiliser `time-control` pour les champs `input[type="time"]`, avec variante `sm` ou `xs` selon le contexte ;
|
||||||
|
- taille normale : `44px` minimum ;
|
||||||
|
- taille compacte `sm` : `36px` minimum ;
|
||||||
|
- taille dense `xs` : `32px` minimum ;
|
||||||
|
- utiliser `field-textarea` pour les textareas redimensionnables avec scrollbar stable : `md` `108px`, `sm` `88px`, `xs` `72px` minimum ;
|
||||||
|
- utiliser `data-variant="ghost"` sur `nds-textarea` pour les zones d'edition intégrées à une surface métier, comme les mémos ;
|
||||||
|
- placeholders via `field-placeholder` ;
|
||||||
|
- hover via `field-hover`, focus via `field-focus` ;
|
||||||
|
- disabled via `field-disabled` ;
|
||||||
|
- les variantes complexes comme les cellules de table et le champ timer `hh:mm:ss` utilisent des mixins dedies du meme fichier.
|
||||||
|
|
||||||
|
Edition inline :
|
||||||
|
|
||||||
|
- utiliser `inline-field` pour les inputs transparents au repos ;
|
||||||
|
- hover/focus via `inline-field-hover` ;
|
||||||
|
- utiliser `inline-textarea` pour les zones texte transparentes intégrées à une carte ;
|
||||||
|
- les outils peuvent changer la couleur texte selon le contexte, par exemple titre de section ou état terminé.
|
||||||
|
|
||||||
|
Selects :
|
||||||
|
|
||||||
|
- taille normale : `44px` minimum ;
|
||||||
|
- taille compacte toolbox : `36px` minimum ;
|
||||||
|
- taille dense `xs` : `32px` minimum ;
|
||||||
|
- fleche custom unique, jamais de chevrons natifs ou empiles ;
|
||||||
|
- fond sombre Nebula force via `background-color`, hover/focus violet avec chevron dore ;
|
||||||
|
- focus visible global dore conserve.
|
||||||
|
|
||||||
|
Select triggers :
|
||||||
|
|
||||||
|
- utiliser `select-trigger` pour les boutons qui ouvrent une liste, un picker ou un calendrier avec le langage visuel d'un select ;
|
||||||
|
- tailles disponibles : `md` `44px`, `sm` `36px`, `xs` `32px` ;
|
||||||
|
- le chevron est le meme asset CSS que `select-control`, pas une icone locale ;
|
||||||
|
- hover via `select-trigger-hover`, focus via `field-focus` et focus visible dore ;
|
||||||
|
- `date-picker-trigger` reste un alias de compatibilite pour les outils deja migrés.
|
||||||
|
|
||||||
|
Menus et palettes :
|
||||||
|
|
||||||
|
- utiliser `floating-control-menu` pour les menus flottants compacts ;
|
||||||
|
- les suggestions `.nds-autocomplete-option` utilisent le même fond doré au survol et en état `.is-active`, sans être écrasées par le hover générique des boutons ;
|
||||||
|
- utiliser `compact-menu-button` pour les options d'icones ou d'actions compactes ;
|
||||||
|
- utiliser `color-swatch`, `color-swatch-rainbow` et `color-swatch-active` pour les palettes couleur ;
|
||||||
|
- utiliser `large-icon-picker-trigger` pour les gros triggers de choix d'icone de toolbox ;
|
||||||
|
- les outils peuvent garder uniquement la position du menu, le nombre de colonnes et la variable de couleur métier.
|
||||||
|
|
||||||
|
Champs horaires :
|
||||||
|
|
||||||
|
- les champs `input[type="time"]` restent natifs ;
|
||||||
|
- utiliser `time-control` pour aligner le champ avec le theme Nebula ;
|
||||||
|
- la popup native du navigateur/OS n'est pas consideree comme stylable de facon fiable en CSS.
|
||||||
|
|
||||||
|
Boutons texte :
|
||||||
|
|
||||||
|
- utiliser `text-button` depuis `website/src/styles/design-system/_controls.scss` ;
|
||||||
|
- taille normale : hauteur minimale `42px` ;
|
||||||
|
- taille compacte `sm` : hauteur minimale `34px` ;
|
||||||
|
- taille dense `xs` : hauteur minimale `30px` ;
|
||||||
|
- le bouton standard sert de variant secondaire par defaut ;
|
||||||
|
- variants disponibles : `primary`, `secondary`, `ghost`, `dashed`, `danger` ;
|
||||||
|
- les boutons texte peuvent utiliser `data-edge` pour ajuster leurs coins et `data-border` pour gérer les bordures jointes ou masquées ;
|
||||||
|
- etat toggler via `.active` ou `.is-active`, avec le mixin `temporary-toggle-active` ;
|
||||||
|
- etat desactive via `control-disabled` ;
|
||||||
|
- les outils peuvent uniquement ajuster la largeur, l'alignement ou l'espacement environnant.
|
||||||
|
|
||||||
|
Boutons icones :
|
||||||
|
|
||||||
|
- taille normale : `40px`, icone `21px` ;
|
||||||
|
- taille compacte : `34px`, icone `19px` ;
|
||||||
|
- taille dense `xs` : `30px`, icone `17px` ;
|
||||||
|
- taille inline `xxs` : `26px`, icone `15px` ;
|
||||||
|
- taille micro reservee aux noeuds denses : `22px`, icone `12px` ;
|
||||||
|
- fond compact sombre, rayon `--radius-md`, hover violet contenu ;
|
||||||
|
- variant `primary` dore/violet pour les actions principales ;
|
||||||
|
- variant `ghost` transparent pour les handles discrets ;
|
||||||
|
- variant `danger` rouge contenu pour les suppressions ;
|
||||||
|
- variant de forme `button-edge(left|middle|right|top|center|bottom|top-left|top-right|bottom-right|bottom-left)` pour ajuster uniquement les coins contre une bordure ou dans un groupe ;
|
||||||
|
- variant de bordure `button-joined-border(...)` pour masquer les bordures de jonction déduites depuis `data-edge` ;
|
||||||
|
- variant de bordure `button-borderless` pour masquer toutes les bordures ;
|
||||||
|
- variant visuel `panel-toggle` pour les boutons icones accolés à un panneau, comme le bouton pages ;
|
||||||
|
- etat toggler via `.active` ou `.is-active`, avec le mixin `temporary-toggle-active`.
|
||||||
|
- les barres d'outils compactes reutilisent la famille bouton icone ; `toolbar-button` reste un alias pratique pour les boutons courts du Bloc Note (`B`, `I`, `U`) et les boutons de dessin.
|
||||||
|
|
||||||
|
Boutons composes et toggles de bord :
|
||||||
|
|
||||||
|
- utiliser `button-edge` + `button-joined-border` pour les boutons groupés type ajout + chevron ;
|
||||||
|
- utiliser `button-edge` pour la forme des toggles accoles au bord d'un module, et `icon-button-panel-toggle` pour leur rendu visuel quand ils reprennent le pattern du bouton `pages` ;
|
||||||
|
- utiliser `list-row-button` pour les lignes selectionnables type pages du Bloc Note ;
|
||||||
|
- utiliser `text-button-dashed` pour les actions d'ajout contextuelles encadrees en pointilles ;
|
||||||
|
- `split-button-start`, `split-button-end`, `edge-toggle` et `dashed-action-button` restent des alias de compatibilite internes, pas des familles visuelles separees ;
|
||||||
|
- les outils peuvent garder uniquement la position absolue, la largeur contextuelle et les icones ;
|
||||||
|
- l'etat actif/hover doit rester dans le DS, sans fond/bordure locale.
|
||||||
|
|
||||||
|
Steppers de quantite :
|
||||||
|
|
||||||
|
- utiliser `quantity-stepper` depuis `website/src/styles/design-system/_controls.scss` ;
|
||||||
|
- variante normale : checklist, boutons `28px` autour de la valeur editable ;
|
||||||
|
- variante compacte : arbre d'objectifs, boutons `18px` ou `16px` selon la densite du noeud ;
|
||||||
|
- hover des boutons via `quantity-stepper-button-hover`, pas via `icon-button-hover` ;
|
||||||
|
- les outils peuvent adapter la grille environnante, mais pas redefinir les boutons `- / +`, le hover ou le champ de valeur.
|
||||||
|
|
||||||
|
Switchs binaires :
|
||||||
|
|
||||||
|
- utiliser `switch-control` depuis `website/src/styles/design-system/_controls.scss` ;
|
||||||
|
- taille `lg` : switchs de module, hauteur `40px`, rail `30 x 16px` ;
|
||||||
|
- taille normale : tool-split, hauteur `34px`, rail `34 x 18px` ;
|
||||||
|
- taille compacte `sm` : contrôles denses, hauteur `28px`, rail `24 x 14px` ;
|
||||||
|
- taille inline `xs` : timer inline, hauteur `26px`, rail `18 x 12px` ;
|
||||||
|
- les variantes natives avec `<label><input type="checkbox" />...` doivent réutiliser `native-switch-label`, `native-switch-input`, `switch-track` et `switch-thumb` ;
|
||||||
|
- les outils peuvent uniquement ajuster l'alignement ou la largeur environnante.
|
||||||
|
|
||||||
|
Radio button groups :
|
||||||
|
|
||||||
|
- la primitive commune est `radio-button-group` / `radio-button-group-option` depuis `website/src/styles/design-system/_controls.scss` ;
|
||||||
|
- tailles disponibles : `md`, `sm`, `xs` ;
|
||||||
|
- cette base couvre les tabs d'outils, switches de layout, choix d'alertes et répétitions calendrier ;
|
||||||
|
- le nombre d'options, le fait d'utiliser une icone, un texte ou les deux ne constitue pas un variant visuel ;
|
||||||
|
- les toggles compacts non natifs peuvent reutiliser ces mixins meme avec des `span`, tant que le comportement reste celui d'un choix exclusif ou d'un choix compact ;
|
||||||
|
- l'etat actif passe par `.active` ou `.is-active` avec le style toggler temporaire ;
|
||||||
|
- les outils peuvent definir la grille ou la largeur, mais pas redefinir localement les fonds, bordures, hovers ou actifs.
|
||||||
|
|
||||||
|
Controles composes :
|
||||||
|
|
||||||
|
- utiliser `compound-toggle-field` pour les cas qui combinent un choix compact et un champ, comme `Hold + number` ;
|
||||||
|
- le bouton interne utilise `compound-toggle-field-option`, base sur `radio-button-group-option(xs)` ;
|
||||||
|
- le champ interne utilise `compound-toggle-field-input`, base sur `field-control(xs)` et `number-spinner-reset` ;
|
||||||
|
- ces controles ne sont pas des variants du groupe de choix, car leur structure mélange action et saisie.
|
||||||
|
|
||||||
|
Checkboxes :
|
||||||
|
|
||||||
|
- utiliser `checkbox-control` depuis `website/src/styles/design-system/_controls.scss` ;
|
||||||
|
- taille normale globale : `20px`, format carre strict `1:1` ;
|
||||||
|
- taille compacte `xs` : `14px` pour les listes denses comme les prérequis de l'arbre d'objectifs ;
|
||||||
|
- etat coche avec gradient dore/violet et coche blanche ;
|
||||||
|
- les outils peuvent ajuster uniquement l'alignement ou l'espacement environnant.
|
||||||
|
|
||||||
|
Date picker et menus compacts :
|
||||||
|
|
||||||
|
- les tabs internes d'outils utilisent les primitives `radio-button-group-surface`, `radio-button-group-option`, `radio-button-group-option-hover` et `radio-button-group-option-active` ;
|
||||||
|
- les boutons du mini calendrier mensuel utilisent `date-picker-button` et `date-picker-button-hover` ;
|
||||||
|
- les boutons de menu/picker compacts utilisent `compact-menu-button` et `compact-menu-button-hover` ;
|
||||||
|
- les boutons transparents integres aux evenements calendrier utilisent `event-surface-button`, `event-surface-button-hover` et les contrastes `event-surface-contrast-default` / `event-surface-contrast-dark` ;
|
||||||
|
- `nds-event-surface[data-variant="default"]` cible un texte blanc/gris blanc sur fond sombre ;
|
||||||
|
- `nds-event-surface[data-variant="dark"]` cible un texte noir/gris noir sur fond clair ;
|
||||||
|
- `nds-event-surface[data-size="md"]` vise les surfaces à partir de `44px` et affiche le titre et la metadata secondaire, par exemple l'heure ;
|
||||||
|
- `nds-event-surface[data-size="sm"]` vise les surfaces autour de `22px` et masque la metadata secondaire ;
|
||||||
|
- `nds-event-surface[data-size="xs"]` vise les surfaces ultra-fines autour de `10px` et affiche uniquement le titre en taille réduite ;
|
||||||
|
- les titres compacts restent dans le flux pour donner une hauteur au bouton, même si la surface ne définit qu'une hauteur minimale ; en `sm`, bordures et padding doivent tenir avec la ligne de titre dans les `22px` ;
|
||||||
|
- `nds-event-surface[data-color="#8b5cf6"]` expose la couleur métier de l'event ; la variable inline `--nds-event-color` applique cette couleur au fond et a la bordure ;
|
||||||
|
- le hover utilise une superposition sombre legere pour rester lisible sur une couleur dynamique inconnue du Sass ;
|
||||||
|
- les couleurs de fond des events restent métier et ne sont pas des variants NDS ;
|
||||||
|
- les fichiers d'outils peuvent conserver les dimensions de grille, le placement du menu et les états métier `disabled`.
|
||||||
|
|
||||||
|
### Curseurs de niveau
|
||||||
|
|
||||||
|
- `input[type="range"].nds-range` expose un curseur natif réutilisable, avec `min`, `max`, `step`, `value` et `disabled` standards ;
|
||||||
|
- tailles : `md` (44 px, poignée 20 px) et `sm` (36 px, poignée 18 px) ;
|
||||||
|
- associer un label explicite et une valeur visible ; conserver les interactions clavier natives ;
|
||||||
|
- rail violet, poignée bordée de doré, hover et focus issus des tokens NDS ;
|
||||||
|
- la page Aniimo affiche tous les curseurs avec `min="0"`, `max="4"`, `step="1"` : 0 retire la capacité des critères, 1–4 fixe son niveau minimum indépendant ;
|
||||||
|
- les règles de filtrage restent dans le composant métier ; le catalogue `/design-system` montre un exemple interactif et l'état désactivé.
|
||||||
|
|
||||||
|
### Cartes Aniimo et variantes
|
||||||
|
|
||||||
|
- la carte d'entrée du guide utilise `games.json > images.aniimosCover` : le portrait d’Emberpup, recadré sur sa tête dans la couverture commune de 120 px ; des marges internes latérales de 15 % dézooment le portrait sans modifier le cadre ; une description courte conserve le gabarit des autres guides ;
|
||||||
|
- MHW et Aniimo partagent les mixins `flip-card-inner`, `flip-card-indicator`, `flip-card-indicator-hover` et `flip-card-face-hover` de `_surfaces.scss` : rotation de 420 ms, surfaces et indicateurs identiques ;
|
||||||
|
- les contrôles de retournement Aniimo sont dans chaque face et tournent avec elle ; le focus clavier rejoint le contrôle de la nouvelle face ;
|
||||||
|
|
||||||
|
- afficher les portraits dans un cadre compact 3 / 4 plafonné à 250 px de hauteur, avec `object-fit: cover` et cadrage centré ; les fichiers source restent inchangés ;
|
||||||
|
- les deux variantes de Petit Esprit de Feu utilisent `object-position: center 20%` pour descendre légèrement le personnage dans son cadre ;
|
||||||
|
- la grille utilise un minimum de 220 px pour afficher cinq colonnes à 1920 px, puis réduit le nombre de colonnes selon la largeur disponible ; limiter les cartes à 280 px de largeur ; le recto fixe leur hauteur avec le portrait et un pied de 96 px, indépendamment des noms et des variantes ;
|
||||||
|
- le nom de forme revient à la ligne au recto, dans le pied fixe de 96 px ;
|
||||||
|
- le recto affiche uniquement les icônes d'éléments, centrées dans le pied de carte, avec texte alternatif et infobulle ;
|
||||||
|
- le numéro Aniimo reste un texte HTML visible en bas à gauche du portrait, afin de rester accessible à la recherche du navigateur ;
|
||||||
|
- les onglets affichent des initiales ou une abréviation de trois lettres pour les formes à un seul mot (PLA, PRA, PRI) ; le nom complet reste accessible via `Tooltip`, au survol et au focus ;
|
||||||
|
- placer les onglets au-dessus du contrôle de retournement, avec navigation clavier ; une grille de colonnes égales affiche les sept formes sur une ligne sans couper les libellés ;
|
||||||
|
- la forme Prismana est la dernière entrée des variantes dans le JSON ; son onglet reçoit un léger fond et un soulignement arc-en-ciel statiques, sans masquer la sélection ou le focus ;
|
||||||
|
- conserver la face affichée lorsqu'une autre variante est sélectionnée ;
|
||||||
|
- le verso répartit les éléments et le rôle sur deux colonnes séparées par un trait vertical très discret, centré dans un espacement de 20 px (10 px de chaque côté), avant les compétences ;
|
||||||
|
- les badges d'élément et de rôle restent sur une ligne dans leur colonne : icône fixe, padding compact et points de suspension sur les libellés trop longs, avec texte complet au survol ;
|
||||||
|
- espacer de 24 px les sections Éléments/Rôle, Compétences de logis et Statistiques au verso ;
|
||||||
|
- le verso ajoute les six statistiques et leur total sous les compétences : barres `nds-progress[data-size="sm"]`, valeur numérique alignée à droite, échelle commune égale au maximum du catalogue complet (130 actuellement), indépendante des filtres ;
|
||||||
|
- le compteur met en évidence les Aniimos ou les variantes selon le mode de copie ; l’infobulle du bouton précise le contenu copié ;
|
||||||
|
- le verso occupe exactement la même surface que le recto ; garder son en-tête fixe et faire défiler les détails dans une zone NDS accessible au clavier ; masquer la face inactive aux technologies d'assistance ;
|
||||||
|
- réduire l'animation selon `prefers-reduced-motion` ;
|
||||||
|
- les filtres se cumulent par famille et sont évalués sur chaque variante avant regroupement ; les compétences de logis sont toujours cumulatives et leur sélecteur compact `≥ / =`, aligné comme « Correspondance », utilise le niveau minimum par défaut ; les libellés complets restent accessibles ;
|
||||||
|
- l’option directe « Prismana » reprend le style et les dimensions des `nds-filter-option`, utilise une étoile SVG arc-en-ciel et limite les résultats à `prismana_form` avant regroupement ; son nom accessible reste « Prismana uniquement » ;
|
||||||
|
- la barre horizontale avancée ouvre trois menus distincts pour Rôles, Éléments et Compétences de logis ; chaque trigger conserve le nombre de critères sélectionnés ;
|
||||||
|
- rôles et éléments utilisent des chips `sm` avec icône et texte, réparties sur deux colonnes via `data-columns="2"` ; le menu des compétences sépare sans titre visible les éléments et les actions en deux colonnes accessibles, le séparateur étant porté par la colonne des éléments, plus haute ; chaque ligne conserve icône, texte, curseur et valeur ; un seuil à 0 reste visible mais ne participe pas au filtrage ;
|
||||||
|
- recherche, Prismana, rôles, éléments et niveaux actifs sont rappelés sous la barre par des badges supprimables individuellement ;
|
||||||
|
- les menus restent ouverts pendant une sélection multiple et se ferment au clic extérieur, à `Escape` ou quand le focus quitte le menu.
|
||||||
|
|
||||||
|
### Liaison des guides aux checklists
|
||||||
|
|
||||||
|
Dans la toolbox latérale, chaque checklist compatible avec le guide ouvert expose
|
||||||
|
sa liaison dans la section Guide du panneau Configuration commun. L'engrenage
|
||||||
|
conserve un marqueur d'état lorsque la liste est associée et expose `aria-expanded`
|
||||||
|
et `aria-controls`. Le bouton NDS
|
||||||
|
« Associer à la page » / « Dissocier » expose `aria-pressed` ; activer une liste
|
||||||
|
dissocie la précédente. Le switch « Masquer les cochés » est désactivé sur les listes
|
||||||
|
non associées. Les commandes sont centrées et reviennent à la ligne en fenêtre étroite ;
|
||||||
|
le nombre de correspondances figure dessous. Une liste active devenue incompatible
|
||||||
|
garde ses commandes avec un message explicite.
|
||||||
|
Un encart compact placé en haut à droite du header du guide expose un sélecteur dès
|
||||||
|
qu'au moins une checklist compatible existe. Sans association, lui seul est affiché.
|
||||||
|
Une fois une liste choisie, l'encart ajoute le switch « Masquer les cochés » et
|
||||||
|
l'action « Dissocier la checklist ». Changer la
|
||||||
|
valeur du sélecteur remplace immédiatement l'association sans perdre le réglage de
|
||||||
|
masquage. La colonne d'actions occupe la hauteur existante du
|
||||||
|
header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette
|
||||||
|
association est conservée dans `localStorage` entre les sessions ; elle est retirée
|
||||||
|
si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît.
|
||||||
|
Le bouton texte « Résultats vers checklist », accompagné de l'icône checklist, est visible
|
||||||
|
dans l'en-tête des pages de résultats filtrables. Son `aria-label` et son infobulle
|
||||||
|
conservent le contexte précis des éléments concernés. Il ouvre une modale NDS qui
|
||||||
|
rappelle que les filtres, le regroupement et le masquage déterminent les éléments
|
||||||
|
exportés. La copie texte reste disponible sans toolbox liée ; l'action de création
|
||||||
|
est alors désactivée et accompagnée d'une notice inline. Avec une toolbox liée, la
|
||||||
|
modale affiche son nom et crée la checklist dans son espace actif.
|
||||||
|
L'état terminé désature et atténue uniquement les portraits (ou les icônes de
|
||||||
|
catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche
|
||||||
|
accessible « Coché » reste sur le verso des cartes retournables. Les textes restent
|
||||||
|
lisibles et les indicateurs absolus ne modifient pas la taille des cartes.
|
||||||
|
Lorsqu'une carte correspond à la checklist associée, une checkbox NDS apparaît en
|
||||||
|
bas à droite de la zone d'image du recto (ou de la carte pour un affixe sans image).
|
||||||
|
Elle met à jour toutes les lignes exactes correspondantes, sans retourner la carte ;
|
||||||
|
pour Aniimo, la forme spécifique reste prioritaire sur l'entrée générale.
|
||||||
|
|
||||||
|
## 18. Scrollbars
|
||||||
|
|
||||||
|
Les scrollbars utilisent `scrollbar-control` depuis `website/src/styles/design-system/_controls.scss`.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- largeur/hauteur WebKit : `6px` ;
|
||||||
|
- `scrollbar-width: thin` pour Firefox ;
|
||||||
|
- track transparent ;
|
||||||
|
- thumb violet/indigo avec hover dore contenu ;
|
||||||
|
- coin transparent pour les zones scrollables dans les deux axes ;
|
||||||
|
- les textareas redimensionnables ajoutent `scrollbar-resizer`, sans fond noir ;
|
||||||
|
- ne pas ajouter de rail noir visible ni de double bordure pres des panneaux.
|
||||||
|
|
||||||
|
## 19. Images Et Assets
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- les pages jeux et toolboxes associees utilisent les images de jeu en cover ;
|
||||||
|
- les images de jeu doivent etre rangees par jeu dans `website/public/static/img/games/<game-id>/` ;
|
||||||
|
- utiliser `card-cover.png` pour les cards et `hero-bg.png` pour le futur fond de hero ;
|
||||||
|
- declarer ces images dans `games.json` via `images.cardCover` et `images.heroBg` ;
|
||||||
|
- les toolboxes libres utilisent une icone de `website/public/static/img/toolbox-icons` ;
|
||||||
|
- `toolbox.png` est l'icone par defaut ;
|
||||||
|
- les SVG utilises comme icones ou masques UI doivent etre ranges directement dans `website/public/static/icons` ;
|
||||||
|
- en cas de collision de nom, reutiliser l'icone existante quand elle couvre le meme usage ; sinon prefixer le fichier par son domaine plutot que creer un sous-dossier ;
|
||||||
|
- `website/public/static/img` reste reserve aux images bitmap, covers, illustrations, captures et visuels editoriaux ;
|
||||||
|
- les categories de jeu utilisent des images representatives, entieres et bien cadrees ;
|
||||||
|
- eviter les images trop grandes dans les cards.
|
||||||
|
|
||||||
|
## 20. Accessibilite
|
||||||
|
|
||||||
|
Minimum attendu :
|
||||||
|
|
||||||
|
- focus visible dore ;
|
||||||
|
- boutons reels pour les actions ;
|
||||||
|
- liens reels pour la navigation ;
|
||||||
|
- labels explicites ou `aria-label` pour les boutons icones ;
|
||||||
|
- contraste suffisant ;
|
||||||
|
- modales avec `role="dialog"` et `aria-modal="true"` ;
|
||||||
|
- ne pas transmettre une information uniquement par couleur.
|
||||||
|
|
||||||
|
## 21. A Eviter
|
||||||
|
|
||||||
|
- accents dores partout ;
|
||||||
|
- hover trop lumineux sur tous les elements ;
|
||||||
|
- mouvements verticaux sur les cards/modules principaux ;
|
||||||
|
- background inline qui remplace le theme commun ;
|
||||||
|
- descriptions de toolbox ;
|
||||||
|
- alertes navigateur pour confirmations ou creation ;
|
||||||
|
- gros blocs explicatifs visibles dans l'app ;
|
||||||
|
- scroll horizontal sur les tableaux de donnees critiques.
|
||||||
|
|
||||||
|
Les chips `nds-chip[data-size="sm"]` utilisent une hauteur minimale de 30 px, un padding de 4 × 8 px, une police `xs` et des icônes/images de 16 px. Le catalogue interactif expose cette taille à côté de la taille par défaut.
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
This repository is being used as a Dolt remote.
|
|
||||||
|
|
||||||
ref=refs/dolt/data
|
|
||||||
|
|
||||||
head=34bb0bb8268bf58b7c9d7a2448667e5cb9b82fe3
|
|
||||||
|
|
||||||
timestamp=2026-10-01T13:17:47Z
|
|
||||||
218
README.md
Normal file
218
README.md
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
# Sokko G
|
||||||
|
|
||||||
|
Webapp React locale pour préparer et accompagner des sessions gaming sur second écran.
|
||||||
|
|
||||||
|
Sokko G propose des toolboxes modulaires stockées dans le navigateur, ainsi que des pages de guides de jeu maintenues dans le dépôt. Aucune donnée utilisateur n’est envoyée côté serveur.
|
||||||
|
|
||||||
|
L'interface cible les fenêtres d'ordinateur en plein écran ou en demi-écran. Les mises en page sont validées à partir de 768 px ; le projet ne prévoit pas d'interface ni de navigation dédiées au téléphone.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Toolboxes locales stockées dans IndexedDB.
|
||||||
|
- Outils disponibles : bloc notes, checklist avec quantités, images, annotation d’images, liens, compteurs et calculateur.
|
||||||
|
- Import/export d’une toolbox.
|
||||||
|
- Import/export global de toutes les toolboxes.
|
||||||
|
- Association locale entre une page jeu et une toolbox.
|
||||||
|
- Bascule Guide / Toolbox sous 1440 px et panneau latéral redimensionnable à partir de 1440 px sur les pages jeux.
|
||||||
|
- Suivi d’un budget de stockage recommandé, avec quota navigateur en information.
|
||||||
|
- Pages de guides de jeu éditables via JSON.
|
||||||
|
- Listes de jeu exportables vers les checklists.
|
||||||
|
|
||||||
|
## Démarrage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Par défaut, le site démarre sur `http://localhost:5173`.
|
||||||
|
|
||||||
|
Pour générer la version statique de production :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Le build est généré dans `website/dist`.
|
||||||
|
`npm start` sert cette version statique avec `server.mjs`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Le serveur et Vite lisent automatiquement un fichier `.env` facultatif à la racine
|
||||||
|
du projet, avec le parseur natif de Node. Les variables déjà définies dans le
|
||||||
|
processus sont prioritaires, y compris lorsqu'elles sont vides.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fichier `.env` est ignoré par git. Pour changer le port localement, modifier `PORT`.
|
||||||
|
|
||||||
|
## Vérifications
|
||||||
|
|
||||||
|
Avant un push ou une mise en ligne :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette commande vérifie la configuration Node/Vite, lance les tests, puis exécute le build React.
|
||||||
|
|
||||||
|
Pour inclure les parcours navigateur, lancer `npm run check:full`. Le check, les
|
||||||
|
E2E de développement et les E2E de production sont exécutés successivement pour
|
||||||
|
éviter la concurrence avec le build.
|
||||||
|
La suite E2E utilise deux workers par défaut pour limiter la contention lors du
|
||||||
|
chargement initial des outils par Vite. Le parallélisme reste ajustable avec
|
||||||
|
`npm run test:e2e -- --workers=4`. Les traces des tests en échec sont conservées
|
||||||
|
dans `test-results` et consultables depuis le rapport Playwright.
|
||||||
|
|
||||||
|
`npm run test:e2e:production` reconstruit l'application puis teste les comportements
|
||||||
|
propres à la production, notamment la visibilité de la prise en main. Cette suite
|
||||||
|
utilise temporairement `127.0.0.1:4173` ; le port doit être libre, aucune instance
|
||||||
|
existante n'est réutilisée. Le navigateur est isolé des données personnelles et
|
||||||
|
le serveur est fermé à la fin. Ses diagnostics sont conservés séparément dans
|
||||||
|
`test-results/production` et `playwright-report/production`. La suite habituelle
|
||||||
|
`npm run test:e2e` reste sur Vite en développement et exclut ces scénarios.
|
||||||
|
|
||||||
|
L'application est enveloppée dans `StrictMode` : en développement (y compris les
|
||||||
|
E2E sur Vite), React rejoue certains rendus et cycles d'effets pour détecter les
|
||||||
|
effets de bord et les nettoyages manquants. Ces contrôles supplémentaires ne
|
||||||
|
s'exécutent pas en production. Les règles recommandées React Hooks, dont
|
||||||
|
`react-hooks/set-state-in-effect`, sont actives dans ESLint.
|
||||||
|
|
||||||
|
Pour mesurer le chargement local des toolboxes, lancer `npm run profile:toolboxes`
|
||||||
|
avec Chromium Playwright installé (`npx playwright install chromium`). Le script
|
||||||
|
construit la production, utilise temporairement `127.0.0.1:4173` (le port doit être
|
||||||
|
libre) et crée ses données dans des profils Chromium temporaires sur disque,
|
||||||
|
sans toucher aux toolboxes personnelles. Il supprime uniquement ses profils
|
||||||
|
temporaires et ferme le navigateur et le serveur à la fin.
|
||||||
|
|
||||||
|
Les quatre scénarios comparent une base vide, 40 toolboxes légères, une toolbox
|
||||||
|
avec 160 images WebP synthétiques et les mêmes images réparties sur 40 toolboxes.
|
||||||
|
Cinq rechargements après une navigation de chauffe mesurent l'affichage de la
|
||||||
|
liste après lecture IndexedDB, la durée de lecture, les tâches longues et le tas
|
||||||
|
JavaScript après GC. Le rapport brut est écrit dans
|
||||||
|
`.local/performance/toolbox-loading.json`, hors Git. Exécuter ce profil seul, sans
|
||||||
|
build ou tests concurrents. Ce n'est ni un budget CI, ni une mesure du démarrage
|
||||||
|
à cache froid, ni de la mémoire totale du navigateur ; les résultats dépendent
|
||||||
|
de la machine et ne couvrent pas le décodage des images dans une toolbox ouverte.
|
||||||
|
Le profil suit les lectures groupées et ciblées et vérifie que les valeurs des
|
||||||
|
outils Images restent sur disque tant qu'aucune toolbox n'est ouverte.
|
||||||
|
|
||||||
|
## Contenu éditable
|
||||||
|
|
||||||
|
Les contenus maintenus à la main sont regroupés dans `website/public/data`.
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
| --- | --- |
|
||||||
|
| `website/public/data/site.json` | Textes généraux du site : home, about, navigation, sidebar, page toolboxes. |
|
||||||
|
| `website/public/data/games.json` | Liste des jeux affichés sur `/games` et contenus des cards jeux. |
|
||||||
|
| `website/public/data/mhwilds/monsters.json` | Données des monstres Monster Hunter Wilds. |
|
||||||
|
| `website/public/data/mhwilds/endemic_life.json` | Données de la faune Monster Hunter Wilds. |
|
||||||
|
| `website/public/data/mhwilds/lists/*.json` | Listes de jeu MHWilds copiables ou convertibles en checklist. |
|
||||||
|
| `website/public/data/mhwilds/lists/index.json` | Index généré automatiquement des listes MHWilds. |
|
||||||
|
| `website/public/data/mhwilds/i18n/fr.json` | Traductions françaises des données MHWilds. |
|
||||||
|
| `website/public/data/mhwilds/i18n/en.json` | Traductions anglaises des données MHWilds. |
|
||||||
|
| `website/public/data/diablo4/affixes_types.json` | Données et filtres des affixes Diablo IV. |
|
||||||
|
| `website/public/data/toolbox-icons.json` | Manifeste des icônes SVG réutilisables. |
|
||||||
|
|
||||||
|
Les images publiques sont dans `website/public/static`.
|
||||||
|
|
||||||
|
Les index de listes sont générés automatiquement par :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lists:index
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script est lancé avant `npm run dev`, `npm run build` et `npm run check`.
|
||||||
|
|
||||||
|
## Pages jeux
|
||||||
|
|
||||||
|
Les pages jeux sont dans `website/src/features/games`.
|
||||||
|
|
||||||
|
- `website/src/features/games/GamesPage.jsx` : liste des jeux disponibles.
|
||||||
|
- `website/src/features/games/GameRoute.jsx` : route vers la page du jeu demandé.
|
||||||
|
- `website/src/features/games/mhwilds/` : vues et composants propres à Monster Hunter Wilds.
|
||||||
|
- `website/src/features/games/diablo4/` : vues et composants propres à Diablo IV.
|
||||||
|
|
||||||
|
Pour ajouter un jeu :
|
||||||
|
|
||||||
|
- ajouter son entrée dans `website/public/data/games.json` ;
|
||||||
|
- créer son dossier dans `website/src/features/games/` si la page nécessite un rendu spécifique ;
|
||||||
|
- brancher sa route dans `website/src/features/games/GameRoute.jsx`.
|
||||||
|
|
||||||
|
## Outils de toolbox
|
||||||
|
|
||||||
|
Les outils de toolbox sont déclarés dans `website/src/features/toolboxes/modules/index.jsx`.
|
||||||
|
Chaque outil possède son propre fichier de composant :
|
||||||
|
|
||||||
|
- `website/src/features/toolboxes/modules/NotepadModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/ChecklistModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/ImagesModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/LinksModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/CountersModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/CalculatorModule.jsx`
|
||||||
|
- `website/src/features/toolboxes/modules/ImageAnnotationModule.jsx`
|
||||||
|
|
||||||
|
Pour ajouter ou maintenir un outil :
|
||||||
|
|
||||||
|
- créer son fichier dans `website/src/features/toolboxes/modules/` ;
|
||||||
|
- l’ajouter au registre `MODULE_COMPONENTS` dans `website/src/features/toolboxes/modules/index.jsx` ;
|
||||||
|
- garder les données persistées via le service IndexedDB `website/src/utils/indexedDbStorage.js`.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── DESIGN_SYSTEM.md
|
||||||
|
├── package.json
|
||||||
|
├── server.mjs
|
||||||
|
├── vite.config.js
|
||||||
|
├── tests/
|
||||||
|
└── website/
|
||||||
|
├── index.html
|
||||||
|
├── public/
|
||||||
|
│ ├── data/
|
||||||
|
│ └── static/
|
||||||
|
└── src/
|
||||||
|
├── main.jsx
|
||||||
|
├── components/
|
||||||
|
├── features/
|
||||||
|
│ ├── games/
|
||||||
|
│ │ ├── diablo4/
|
||||||
|
│ │ └── mhwilds/
|
||||||
|
│ └── toolboxes/
|
||||||
|
│ └── modules/
|
||||||
|
└── styles/
|
||||||
|
├── _base.scss
|
||||||
|
├── _cards.scss
|
||||||
|
├── _diablo4.scss
|
||||||
|
├── _games.scss
|
||||||
|
├── _home.scss
|
||||||
|
├── _icons.scss
|
||||||
|
├── _mixins.scss
|
||||||
|
├── _mhwilds.scss
|
||||||
|
├── _nebula.scss
|
||||||
|
├── _overlays.scss
|
||||||
|
├── _responsive.scss
|
||||||
|
├── _shell.scss
|
||||||
|
├── _tokens.scss
|
||||||
|
├── _toolboxes.scss
|
||||||
|
└── main.scss
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stockage local
|
||||||
|
|
||||||
|
Les données utilisateur sont stockées dans IndexedDB, dans la base `sokkog`.
|
||||||
|
|
||||||
|
Espaces principaux :
|
||||||
|
|
||||||
|
- `kv` : registre des toolboxes, associations jeu -> toolbox et réglages locaux.
|
||||||
|
- `modules` : données de chaque outil, indexées par `{toolboxId}:{moduleId}`.
|
||||||
|
|
||||||
|
La barre affichée utilise un budget recommandé de confort pour l’app. Le quota navigateur réel vient de `navigator.storage.estimate()` et peut être beaucoup plus élevé selon l’espace disque disponible. Les images et annotations restent locales et peuvent occuper beaucoup d’espace ; les exports globaux restent recommandés pour sauvegarder les données.
|
||||||
16
docs/AGENTS.md
Normal file
16
docs/AGENTS.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
Cette zone contient les références projet.
|
||||||
|
|
||||||
|
## Stockage et workflows
|
||||||
|
|
||||||
|
`STORAGE_SCHEMA.md` est la référence des formats IndexedDB et export/import.
|
||||||
|
|
||||||
|
`FEATURE_CHECKLIST.md` décrit les attentes fonctionnelles des toolboxes.
|
||||||
|
|
||||||
|
Mettre ces documents à jour quand un changement modifie :
|
||||||
|
|
||||||
|
- la structure persistée ;
|
||||||
|
- les formats d'import/export ;
|
||||||
|
- le workflow attendu d'un outil ;
|
||||||
|
- les validations associées.
|
||||||
191
docs/AUDIT_UX_2026-10-05.md
Normal file
191
docs/AUDIT_UX_2026-10-05.md
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<!-- Rôle : consigne l'audit d'ergonomie, les observations vérifiées et les améliorations proposées. -->
|
||||||
|
# Audit d'ergonomie de Sokko G
|
||||||
|
|
||||||
|
Date : 5 octobre 2026.
|
||||||
|
|
||||||
|
## Diagnostic
|
||||||
|
|
||||||
|
Sokko G possède une identité visuelle cohérente et une vraie utilité : rassembler les informations personnelles d'une session et les consulter à côté de guides de jeu. Ses 17 outils couvrent déjà de nombreux usages. Le principal axe d'amélioration est de rendre le premier usage évident, puis de raccourcir l'accès aux contenus personnels.
|
||||||
|
|
||||||
|
Aujourd'hui, l'interface expose assez tôt ses possibilités d'organisation, ses réglages et sa gestion des données. Elle explique moins directement quelle action choisir pour répondre à un besoin simple : noter une information, préparer une liste, retrouver sa session ou suivre les éléments d'un guide.
|
||||||
|
|
||||||
|
Les problèmes fonctionnels ci-dessous sont observés ou établis dans le code. Les conséquences sur la compréhension et les bénéfices attendus restent des hypothèses ergonomiques à valider avec des utilisateurs.
|
||||||
|
|
||||||
|
## Périmètre et méthode
|
||||||
|
|
||||||
|
- Lecture des routes, contenus JSON, composants de navigation, accueil, catalogue, toolboxes, modales, sauvegardes, intégration des guides et styles responsive.
|
||||||
|
- Parcours Chromium dans un profil temporaire : accueil, liste vide des toolboxes, création, ouverture d'une toolbox, choix d'un outil, catalogue, liste des jeux, accueil Aniimo et ouverture de son panneau toolbox.
|
||||||
|
- Inspection à 1440 × 1000 et 390 × 844, avec captures et mesures de mise en page.
|
||||||
|
- Contrôle ponctuel du focus et de la touche Échap dans « Ajouter un outil ».
|
||||||
|
- Aucune erreur JavaScript remontée par `pageerror` pendant ces parcours. Aucun débordement horizontal du document détecté dans les vues capturées.
|
||||||
|
|
||||||
|
Limites : vérification sur Vite en développement, sans session avec de vrais utilisateurs, sans audit exhaustif d'accessibilité, sans mesure de performance et sans validation complète des 17 outils. Les filtres détaillés des jeux ont été examinés dans le code, pas exercés dans le navigateur. L'observation initiale à 390 px a permis de retirer une interface téléphone qui ne correspondait pas à la cible produit ; la cible retenue est désormais une fenêtre d'ordinateur à partir de 768 px.
|
||||||
|
|
||||||
|
Le mode développement conserve la prise en main sur l'accueil, même après création. En production, le code la masque dès qu'une toolbox existe. L'entrée « Design system » visible pendant l'audit est également propre au développement : ce n'est pas un défaut de navigation en production.
|
||||||
|
|
||||||
|
## Points à conserver
|
||||||
|
|
||||||
|
- Identité Nebula, palette sombre et composants visuels communs : la base est reconnaissable et cohérente.
|
||||||
|
- Navigation principale courte sur ordinateur.
|
||||||
|
- Création d'une toolbox avec un seul champ obligatoire.
|
||||||
|
- Prise en main existante, exemples interactifs et documentation par outil.
|
||||||
|
- Organisation du catalogue en cinq familles : notes et suivi, références, calcul et données, temps et routines, builds et commandes.
|
||||||
|
- Possibilité d'associer une toolbox à un jeu et d'utiliser les résultats d'un guide pour créer une checklist.
|
||||||
|
- Stockage local, exports et sauvegarde automatique déjà prévus.
|
||||||
|
- Confirmations existantes pour retirer un outil ou supprimer une toolbox : il faut les conserver, pas ajouter des confirmations partout.
|
||||||
|
|
||||||
|
## Priorités proposées
|
||||||
|
|
||||||
|
Les efforts sont relatifs, pas des engagements de délai : faible = changement local ; moyen = plusieurs composants ou états ; élevé = nouvelle logique et validation de données.
|
||||||
|
|
||||||
|
| Priorité | Amélioration | Observation ou justification | Effort |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P1 | Recentrer la navigation sur le second écran | La navigation téléphone ne correspond pas à la cible d'utilisation. | Faible |
|
||||||
|
| P1 | Harmoniser les libellés de navigation | Conserver les mêmes destinations dans la sidebar large et compacte. | Faible |
|
||||||
|
| P1 | Fiabiliser les modales au clavier | Dans l'ajout d'outil, le focus reste derrière la fenêtre et Échap ne la ferme pas. | Moyen |
|
||||||
|
| P1 | Donner une explication à chaque outil | Mis en œuvre : les 17 choix affichent leur nom complet et une phrase d'usage. | Réalisé |
|
||||||
|
| P1 | Faire de la création la première action | Mis en œuvre : l'action du hero crée la première toolbox, puis mène à la liste. | Réalisé |
|
||||||
|
| P1 | Placer les toolboxes avant les réglages | En fenêtre compacte, sauvegardes et préférences précèdent la première toolbox et la repoussent sous le premier écran. | Moyen |
|
||||||
|
| P2 | Proposer quelques modèles par usage | Une nouvelle toolbox contient surtout un mémo de bienvenue et un lien vers le catalogue. | Moyen |
|
||||||
|
| P2 | Donner une suite à la démonstration | Le catalogue permet d'essayer les outils, sans action directe pour ajouter l'exemple à sa toolbox. | Moyen |
|
||||||
|
| P2 | Rendre visible la création de checklist depuis un guide | Mis en œuvre : « Résultats vers checklist » et son icône dédiée remplacent l'icône seule sur les listes filtrables. | Réalisé |
|
||||||
|
| P2 | Clarifier enregistrement local et sauvegarde externe | « IndexedDB », export, import et budget de stockage occupent beaucoup de place dans le parcours courant. | Moyen |
|
||||||
|
| P2 | Alléger les barres d'actions et notifications | Partiellement réalisé : confirmations à 6 secondes, alertes Timer, Calendrier et sauvegarde persistantes. | Moyen |
|
||||||
|
| P3 | Accueil de reprise, recherche et favoris | L'accueil ne propose pas de raccourci vers une toolbox récente ; la liste n'a pas de recherche. | Moyen à élevé |
|
||||||
|
|
||||||
|
## Recommandations détaillées
|
||||||
|
|
||||||
|
### 1. Une navigation stable en fenêtre large ou compacte
|
||||||
|
|
||||||
|
**Constat vérifié.** L'ancienne barre mobile ne correspondait ni au second écran ni à la fenêtre demi-écran visés par Sokko G. Elle a été retirée au profit d'une sidebar compacte conservée à partir de 768 px.
|
||||||
|
|
||||||
|
**Proposition.** Utiliser des destinations stables dans la sidebar et identifier chaque icône par une infobulle au survol et au focus. Garder la création dans les pages concernées.
|
||||||
|
|
||||||
|
**Validation attendue.** De 768 à 1920 px, toutes les destinations sont identifiables et aucun contrôle fixe ne recouvre le contenu. Sous 768 px, l'application reste accessible sans promesse de confort.
|
||||||
|
|
||||||
|
### 2. Montrer immédiatement ce que l'on peut faire
|
||||||
|
|
||||||
|
**État actuel.** Sans toolbox, le premier bloc affiche désormais « Créer ma première toolbox » et ouvre la création avec le tutoriel activé. Dès qu'une toolbox existe, l'action devient « Voir mes toolboxes ». Le bouton placé après les étapes reste disponible comme rappel.
|
||||||
|
|
||||||
|
**Proposition de texte.** « Vos notes, listes et outils de jeu, réunis au même endroit. Créez une toolbox pour préparer votre prochaine session. Vos données restent dans ce navigateur. »
|
||||||
|
|
||||||
|
**Actions proposées.** Mettre « Créer ma première toolbox » dans le premier bloc, puis « Essayer les outils » en action secondaire. Les guides restent accessibles dans la navigation. Après une première utilisation, remplacer ce bloc par « Reprendre ma session » avec les dernières toolboxes ouvertes.
|
||||||
|
|
||||||
|
Réduire les compteurs « 0 toolbox / 17 outils » à une information secondaire. Déplacer l'histoire du nom vers « C'est quoi Sokko G ? », avec un lien discret depuis l'accueil. Conserver les illustrations comme repères visuels.
|
||||||
|
|
||||||
|
### 3. Rendre le choix d'un outil compréhensible
|
||||||
|
|
||||||
|
**État actuel.** La fenêtre d'ajout s'étend jusqu'à 900 px et présente les 17 icônes, les noms complets et une courte phrase d'usage. Elle affiche quatre ou cinq colonnes selon la largeur et répartit directement les outils dans les cinq sections du catalogue, sans onglets supplémentaires. Le panneau NDS gère le défilement vertical. L'ajout rapide au chevron reste inchangé dans son principe et utilise les infobulles NDS.
|
||||||
|
|
||||||
|
**Proposition.** Réutiliser les cinq familles du catalogue, afficher les noms complets et une phrase d'usage. Ajouter une recherche par nom et intention, par exemple « noter », « loot », « temps ». Mettre trois choix de démarrage en évidence sans masquer les autres.
|
||||||
|
|
||||||
|
| Outil | Description proposée |
|
||||||
|
| --- | --- |
|
||||||
|
| Mémos | Garder une information courte sous les yeux. |
|
||||||
|
| Bloc-notes | Rédiger un guide personnel ou des notes détaillées. |
|
||||||
|
| Checklist | Cocher des objectifs et suivre des quantités. |
|
||||||
|
| Planificateur de tâches | Suivre des tâches quotidiennes ou hebdomadaires. |
|
||||||
|
| Compteurs | Compter des essais, victoires ou ressources. |
|
||||||
|
| Arbre d'objectifs | Décomposer un objectif en étapes et ressources. |
|
||||||
|
|
||||||
|
Ces distinctions sont particulièrement utiles pour les outils dont les usages se recoupent. Un lien « Voir un exemple » peut ouvrir la bonne section du catalogue.
|
||||||
|
|
||||||
|
### 4. Donner un résultat utile dès la création
|
||||||
|
|
||||||
|
**Constat.** La création standard ajoute des Mémos et des Liens. La visite guidée est demandée par le bouton dédié de l'accueil, mais pas par toutes les entrées « Nouvelle toolbox ». Le tutoriel commence par changer l'icône et renommer, avant d'ajouter un outil.
|
||||||
|
|
||||||
|
**Proposition.** Proposer une option facultative de modèle, sans transformer la création en long formulaire :
|
||||||
|
|
||||||
|
- « Notes de session » : bloc-notes et liens.
|
||||||
|
- « Collecte et progression » : checklist et compteurs.
|
||||||
|
- « Routine quotidienne » : planificateur et minuteur.
|
||||||
|
- « Préparer un build » : équipements, notes et références.
|
||||||
|
- « Personnaliser moi-même » : départ libre.
|
||||||
|
|
||||||
|
Guider d'abord vers une action utile : ajouter une note ou cocher un objectif. Montrer ensuite les possibilités de personnalisation. Proposer de relancer l'aide depuis la toolbox.
|
||||||
|
|
||||||
|
Les modèles sont une piste de conception à tester. Leur implémentation doit respecter le stockage et les formats d'export existants.
|
||||||
|
|
||||||
|
### 5. Mettre les contenus personnels au premier plan
|
||||||
|
|
||||||
|
**Constat vérifié.** La page Toolboxes affiche successivement une présentation, cinq actions, les préférences d'alertes et une grande carte d'explication du stockage avant la première toolbox en fenêtre compacte.
|
||||||
|
|
||||||
|
**Proposition.** Afficher un titre court, « Nouvelle toolbox », puis les toolboxes. Déplacer le positionnement des alertes dans les préférences et regrouper les actions de données dans « Sauvegardes ». Conserver un résumé visible et rassurant : « Enregistré sur cet appareil » avec un accès aux détails.
|
||||||
|
|
||||||
|
Ajouter recherche et favoris lorsque la collection grandit. Un tri « Dernière ouverture » nécessite un suivi local dédié ; la date de modification existante n'est pas un historique de consultation.
|
||||||
|
|
||||||
|
### 6. Relier découverte et utilisation
|
||||||
|
|
||||||
|
**Catalogue.** Les exemples interactifs constituent un bon support d'apprentissage. Ajouter « Ajouter cet outil à une toolbox » à proximité de chaque exemple, avec le choix « Vide » ou « Avec cet exemple ». Expliquer localement que les essais de la démo ne sont pas conservés dans les toolboxes.
|
||||||
|
|
||||||
|
**Guides de jeu.** L'action visible « Résultats vers checklist », accompagnée de l'icône checklist, ouvre désormais la modale de copie ou de création, tout en gardant un libellé accessible contextuel. La destination liée et l'absence de toolbox sont expliquées dans la modale. Le choix ou la création d'une toolbox dans ce même parcours reste une amélioration possible.
|
||||||
|
|
||||||
|
**Panneau latéral.** Afficher le nom de la toolbox liée dans le déclencheur ou à proximité. En fenêtre compacte, la bascule « Guide / Toolbox » évite de perdre le contexte dans un panneau couvrant la page.
|
||||||
|
|
||||||
|
### 7. Clarifier la conservation des données
|
||||||
|
|
||||||
|
**Constat.** Le site explique déjà que les données restent locales et propose des exports ainsi qu'une sauvegarde automatique. Le vocabulaire technique et les détails de fonctionnement sont cependant très présents.
|
||||||
|
|
||||||
|
**Proposition.** Distinguer deux informations :
|
||||||
|
|
||||||
|
- « Enregistré dans ce navigateur » : persistance courante des modifications, à afficher après confirmation effective de l'écriture.
|
||||||
|
- « Dernière sauvegarde en fichier : … » : copie récupérable hors de ce navigateur.
|
||||||
|
|
||||||
|
Employer « Télécharger une sauvegarde » et « Restaurer une sauvegarde » aux endroits appropriés, tout en explicitant si l'import ajoute, fusionne ou remplace des données. Garder les détails IndexedDB et quota dans une rubrique secondaire. La jauge de 250 Mio correspond à un budget recommandé, pas au quota réel du navigateur : éviter un affichage qui suggérerait une limite technique ferme.
|
||||||
|
|
||||||
|
Dans la fenêtre de sauvegarde automatique, mettre d'abord l'état, le dossier et l'action suivante ; replier les explications longues sous « Comment ça fonctionne ».
|
||||||
|
|
||||||
|
### 8. Réduire l'effort de lecture pendant une session
|
||||||
|
|
||||||
|
**Constat visuel.** Les en-têtes d'outils cumulent déplacement, défilement, ajout, réglages, suppression et repli. Le mémo de bienvenue est également visible dans des bandes déroulantes. Les cartes, contours et accents occupent une place importante dans la hiérarchie.
|
||||||
|
|
||||||
|
**Proposition.** Garder visibles l'ajout et les actions fréquentes ; regrouper les actions rares dans « Plus ». Ajouter un libellé aux actions essentielles dans les fenêtres compactes. Envisager un mode « Organiser » explicite pour les déplacements et changements de disposition, après test pour préserver l'efficacité des utilisateurs habitués.
|
||||||
|
|
||||||
|
Réserver l'accent le plus fort à l'action principale du bloc. Rendre les bandes de mémos facultatives au démarrage. Les confirmations ordinaires disparaissent désormais après 6 secondes ; les alertes Timer, Calendrier et sauvegarde restent affichées jusqu'à leur fermeture manuelle.
|
||||||
|
|
||||||
|
### 9. Corriger les interactions clavier des fenêtres
|
||||||
|
|
||||||
|
**Constat vérifié.** À l'ouverture de « Ajouter un outil », le focus reste sur le bouton déclencheur hors de la modale. Échap ne ferme pas la fenêtre. Le composant ne prévoit pas de confinement du focus. Plusieurs autres modales utilisent un fonctionnement similaire, à contrôler individuellement.
|
||||||
|
|
||||||
|
**Proposition.** Standardiser l'ouverture avec focus dans la fenêtre, la navigation Tab à l'intérieur, la fermeture par Échap et le retour au déclencheur. Rendre l'arrière-plan inactif pendant la modale. Compléter la navigation avec l'indication programmatique de la page courante et un accès direct au contenu principal.
|
||||||
|
|
||||||
|
Le contrôle des contrastes, des cibles tactiles et des lecteurs d'écran reste à réaliser ; cet audit ne conclut pas à une conformité globale d'accessibilité.
|
||||||
|
|
||||||
|
## Ordre de réalisation conseillé
|
||||||
|
|
||||||
|
1. Corriger les défauts vérifiés : navigation de second écran, noms tronqués, focus et fermeture des modales.
|
||||||
|
2. Simplifier le premier parcours : bouton de création immédiat, catalogue d'ajout descriptif, contenus personnels avant les réglages.
|
||||||
|
3. Améliorer les passerelles : exemples vers toolbox et résultats de guide vers checklist.
|
||||||
|
4. Tester les évolutions plus structurantes : modèles, accueil de reprise, favoris, mode d'organisation.
|
||||||
|
|
||||||
|
Éviter d'ajouter d'abord de nouveaux outils : les fonctions actuelles offrent déjà suffisamment de matière pour améliorer la découverte et l'usage quotidien.
|
||||||
|
|
||||||
|
## Comment vérifier les progrès
|
||||||
|
|
||||||
|
Organiser une courte séance avec, par exemple, cinq personnes découvrant le site. Ce petit groupe sert à repérer des obstacles, pas à établir des statistiques représentatives. Leur donner les mêmes objectifs avant et après les changements :
|
||||||
|
|
||||||
|
1. Expliquer à quoi sert le site et où les données sont conservées.
|
||||||
|
2. Créer un espace pour une session et y noter une information.
|
||||||
|
3. Choisir entre checklist et tâches récurrentes pour deux besoins distincts.
|
||||||
|
4. Utiliser un guide pour constituer une checklist personnelle.
|
||||||
|
5. Retrouver la toolbox après rechargement.
|
||||||
|
6. Télécharger une sauvegarde et expliquer comment la restaurer.
|
||||||
|
|
||||||
|
Relever la réussite sans aide, les hésitations, les retours arrière et le temps jusqu'au premier contenu utile. Fixer les objectifs chiffrés après cette première mesure. Inclure un parcours au clavier et des fenêtres réelles de 768 et 960 px.
|
||||||
|
|
||||||
|
## Références dans le dépôt
|
||||||
|
|
||||||
|
- [Accueil et différence développement/production](../website/src/pages/HomePage.jsx)
|
||||||
|
- [Prise en main](../website/src/pages/HomeOnboardingSection.jsx)
|
||||||
|
- [Libellés éditoriaux](../website/public/data/site.json)
|
||||||
|
- [Navigation globale](../website/src/components/Shell.jsx)
|
||||||
|
- [Règles responsive des fenêtres compactes](../website/src/styles/_responsive.scss)
|
||||||
|
- [Pages, tutoriel et panneau toolbox](../website/src/features/toolboxes/ToolboxPages.jsx)
|
||||||
|
- [Sélection d'outils](../website/src/features/toolboxes/modules/AddToolControls.jsx)
|
||||||
|
- [Registre des 17 outils et familles](../website/src/features/toolboxes/toolboxModuleRegistry.js)
|
||||||
|
- [Catalogue et exemples](../website/src/pages/LibraryPage.jsx)
|
||||||
|
- [Résultats de guides vers checklist](../website/src/features/games/CopyChecklistItemsButton.jsx)
|
||||||
|
- [Modales et sauvegarde automatique](../website/src/components/ToolboxModals.jsx)
|
||||||
|
- [Durée des notifications](../website/src/main.jsx)
|
||||||
|
|
||||||
|
Le recentrage responsive issu de cet audit est implémenté ; les autres recommandations restent des pistes de travail hors de ce chantier.
|
||||||
127
docs/FEATURE_CHECKLIST.md
Normal file
127
docs/FEATURE_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Feature Checklist
|
||||||
|
|
||||||
|
Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page jeu ou d'un format de données.
|
||||||
|
|
||||||
|
## Cible d'affichage
|
||||||
|
|
||||||
|
- Concevoir et valider les fonctionnalités pour une fenêtre d'ordinateur en plein écran ou en demi-écran, à partir de `768px`.
|
||||||
|
- Ne pas ajouter de navigation, de composant, de libellé ou de parcours réservé au téléphone.
|
||||||
|
- Sous `768px`, vérifier uniquement que le contenu reste accessible, que la sidebar demeure utilisable et qu'aucun débordement horizontal global n'est introduit.
|
||||||
|
- Conserver les adaptations internes nécessaires aux composants étroits, notamment les formulaires, tableaux et outils, sans en faire une interface mobile distincte.
|
||||||
|
|
||||||
|
## Outil Toolbox
|
||||||
|
|
||||||
|
- Ajouter ou modifier le composant dans `website/src/features/toolboxes/modules/`.
|
||||||
|
- Déclarer ses métadonnées et son chargeur dans `website/src/features/toolboxes/toolboxModuleRegistry.js`. Le registre React `modules/toolboxModuleComponents.jsx` est partagé par la toolbox et la Librairie ; `modules/index.jsx` expose le shell.
|
||||||
|
- Vérifier le chargement à la demande en toolbox et dans chaque catégorie de la Librairie.
|
||||||
|
- Dans la Librairie, vérifier les liens directs vers un outil, les changements d'ancre et le retour navigateur ; une nouvelle sélection ou une sortie de page doit annuler les défilements différés précédents.
|
||||||
|
- Ajouter les textes nécessaires dans `website/public/data/site.json`.
|
||||||
|
- Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`.
|
||||||
|
- Pour chaque type du registre, fournir une description non vide dans `toolboxes.addToolDescriptions` et vérifier que la fenêtre « Ajouter un outil » affiche le nom complet et la courte phrase d'usage aux largeurs compactes validées. Vérifier sa largeur réelle jusqu'à `900px`, les cartes compactes avec icône à gauche du titre, les 4 et 5 colonnes selon la largeur, les cinq sections sans onglets, les couleurs et dimensions de `scrollbar-control`, ainsi que les infobulles de l'ajout rapide au survol comme au focus.
|
||||||
|
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
|
||||||
|
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
|
||||||
|
- Vérifier l'import et l'export si l'outil stocke des données.
|
||||||
|
- Pour une modification de persistance, vérifier les écritures successives en succès/échec, les suppressions, ainsi qu'un import concurrent avec une édition ; un échec ne doit pas restaurer une valeur jamais confirmée.
|
||||||
|
- Vérifier qu'une erreur d'ouverture ou de lecture au démarrage bloque les écritures, créations, imports et exports sans afficher une fausse liste vide ; plusieurs tentatives puis une reprise réussie doivent retrouver les données initiales sans recharger la page ni les effacer.
|
||||||
|
- Pour Images et Annotation d'images, vérifier la lecture différée en page complète et panneau latéral, la reprise après échec local et l'absence d'écrasement pendant le chargement. Les alertes des autres outils doivent rester actives sans ouvrir les médias.
|
||||||
|
- Vérifier qu'une création, un import ou une suppression préserve les médias non lus des autres outils ; les exports doivent les inclure et refuser un téléchargement partiel en cas d'échec. Une lecture en cours ne doit pas réintroduire des données supprimées.
|
||||||
|
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis la section Données du panneau Configuration.
|
||||||
|
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
|
||||||
|
- Vérifier l'affichage dans la page toolbox complète.
|
||||||
|
- Vérifier l'affichage dans le panneau latéral.
|
||||||
|
- Vérifier l'ordre compact du header en une et deux colonnes et dans une fenêtre étroite : déplacement, icône, titre, scroll éventuel, ajout éventuel, Configuration, suppression, chevron. Le panneau Configuration doit prendre toute la largeur, placer Espace et Données sur deux colonnes puis les réglages spécifiques sur toute la ligne, et empiler ces sections sous la largeur prévue.
|
||||||
|
- Vérifier que Configuration et ajout se ferment mutuellement, qu'Échap ferme Configuration et qu'un outil replié peut l'ouvrir sans afficher son contenu métier.
|
||||||
|
- Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente.
|
||||||
|
- Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et fenêtre compacte.
|
||||||
|
- Vérifier qu'un changement de page avec le focus actif remplace immédiatement le contenu de l'éditeur ; une mise à jour externe de la même page attend le blur sans déplacer la sélection pendant la saisie ni reprendre le focus ensuite.
|
||||||
|
- Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé.
|
||||||
|
- Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ.
|
||||||
|
- Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image.
|
||||||
|
- Pour la galerie d'images, vérifier les boutons de taille `−` / `+`, leur blocage aux limites, le switch de ratio `4:3` / `1:1`, la persistance de ces préférences après rechargement, les images et libellés contenus dans les deux formats, la redistribution centrée des vignettes, le padding périphérique et l'absence de scroll horizontal.
|
||||||
|
- Pour l'éditeur de couverture, vérifier les décodages concurrents, les erreurs après fermeture et la libération des bitmaps/URLs ; une image en chargement ne doit pas permettre de valider la couverture précédente.
|
||||||
|
- Les parcours `e2e/toolbox-cover.spec.js` vérifient le choix d'icône, l'enregistrement d'une couverture 620 × 248, sa persistance après rechargement et import/export, l'annulation et les fichiers illisibles. L'association à un jeu doit afficher sa couverture sans perdre l'image personnalisée, restaurée après dissociation ; choisir une icône doit supprimer la couverture personnalisée.
|
||||||
|
- Les parcours `e2e/table.spec.js` vérifient les en-têtes éditables sans changer les références des formules, le redimensionnement sans restauration des cellules supprimées, la navigation et la sélection clavier, la copie des valeurs TSV, le collage et l'effacement de blocs, le déplacement à la souris et l'insertion d'une plage dans une formule. Les valeurs et dimensions doivent survivre au rechargement.
|
||||||
|
- Les parcours `e2e/text-exchange.spec.js` vérifient les modales partagées via Tableau et Calculateur : export désactivé à vide, validation des imports, conservation des données après erreur, brouillon effacé à la réouverture, export en lecture seule et copie exacte. L'import doit persister avec ses formules, libellés et relations ; Annuler, Fermer, Échap et le fond de modale doivent fermer correctement, avec restitution du défilement.
|
||||||
|
- Les parcours `e2e/task-planner.spec.js` couvrent création, renommage, description multiligne, catégorie, suppression et persistance. Un parent transmet sa catégorie ; un prérequis manquant avertit sans bloquer la validation. Supprimer un parent doit conserver l'enfant sans relation orpheline. Avec une horloge contrôlée, les tâches quotidiennes et hebdomadaires se réinitialisent, les ponctuelles restent cochées ; masquage et nettoyage doivent préserver les autres tâches.
|
||||||
|
- Pour Mémos, vérifier l'édition courte, les couleurs, la réorganisation et la bande déroulante ; vérifier aussi les liaisons Aucun / Tous / planificateur précis, les alertes de tâches proches du reset et le retour à Aucun après suppression du dernier planificateur lié.
|
||||||
|
- Les parcours `e2e/calculator.spec.js` vérifient la validation des formules, l'édition parent/enfant avec recalcul et persistance, la suppression d'une branche active, le retour à un calcul racine, la réinitialisation du brouillon sans effacer les résultats, la copie pour checklist et la persistance du défilement. La réorganisation des racines doit conserver leurs descendants et refuser de changer le parent d'un enfant.
|
||||||
|
- Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur.
|
||||||
|
- Vérifier que les confirmations ordinaires disparaissent après 6 secondes, que les durées explicites sont respectées et que les alertes Timer, Calendrier et sauvegarde restent présentes jusqu'à leur fermeture manuelle. La pile reste limitée à cinq alertes.
|
||||||
|
- Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu.
|
||||||
|
- Vérifier le mode une colonne et deux colonnes.
|
||||||
|
- Vérifier que l'ajout, la suppression et la réorganisation respectent les `moduleOrder.one` et `moduleOrder.two` de l'espace actif.
|
||||||
|
- Pour les espaces toolbox, vérifier la conversion de l'ancien ordre vers « Défaut », la limite de cinq, l'espace actif partagé avec le panneau jeu, l'ajout dans l'espace actif, le renommage, le réordonnancement hors défaut et la suppression avec rapatriement des outils.
|
||||||
|
- Vérifier le déplacement d'un outil par sélecteur et par dépôt sur onglet : dépôt rapide sans changement d'espace, activation après 600 ms, dépôt précis après activation et `pointercancel` sans déplacement.
|
||||||
|
- Pour les déplacements par pointeur, vérifier les dépôts avant/après, le rejet des cibles interdites et l'annulation navigateur (`pointercancel`) sans modification de l'ordre ; le démontage doit retirer les listeners actifs.
|
||||||
|
- Vérifier le quota de stockage si l'outil manipule des données lourdes.
|
||||||
|
- Pour la sauvegarde locale, vérifier la feature detection, les permissions sans popup au démarrage, le nommage en date locale, la déduplication quotidienne, le remplacement manuel, la rétention de dix fichiers et la conservation des fichiers étrangers.
|
||||||
|
|
||||||
|
## Page Jeu
|
||||||
|
|
||||||
|
- Ajouter les données dans `website/public/data/`.
|
||||||
|
- Ajouter les images dans `website/public/static/img/games/<gameId>/`.
|
||||||
|
- Déclarer le jeu dans `website/public/data/site.json`.
|
||||||
|
- Vérifier la page `/games`.
|
||||||
|
- Vérifier la page `/games/<gameId>`.
|
||||||
|
- Vérifier le chargement à la première visite, la conservation des données pendant la navigation et les erreurs réseau ; sous StrictMode, les requêtes abandonnées ne doivent pas remplacer les données actuelles ni afficher d'erreur d'annulation.
|
||||||
|
- Sous `1440px`, vérifier la bascule « Guide / Toolbox », la conservation des deux vues après leur première activation et la restauration de leur défilement. À partir de `1440px`, vérifier que le bouton « Afficher la toolbox » devient « Masquer la toolbox » avec le panneau latéral ouvert.
|
||||||
|
- Sans toolbox liée, vérifier la création ainsi que l'association directe d'une toolbox existante ; le sélecteur ne doit proposer que les toolboxes qui ne sont liées à aucun jeu.
|
||||||
|
- Vérifier le breadcrumb.
|
||||||
|
- Vérifier les textes avec retours à la ligne.
|
||||||
|
- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes.
|
||||||
|
- Vérifier le changement de checklist, la dissociation, la restauration de session, la suppression d'un module et le changement de toolbox. La réinitialisation des filtres conserve la liaison ; une liste devenue sans correspondance reste sélectionnée avec un message.
|
||||||
|
- Pour les résultats filtrables Aniimo, MHW et Diablo, vérifier que le bouton visible « Résultats vers checklist » utilise l'icône checklist, conserve un libellé accessible contextuel et ouvre une modale rappelant que seuls les éléments visibles sont utilisés. Le groupe titre, compteur et action doit revenir à la ligne sans débordement dès 768 px. La copie reste disponible sans toolbox liée ; la création est alors désactivée avec une indication explicite. Avec une toolbox liée, la création ajoute une nouvelle checklist dans son espace actif sans remplacer l'association guide/checklist existante.
|
||||||
|
- Pour Aniimo, vérifier la copie et la création des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs, copie et création doivent correspondre aux résultats affichés.
|
||||||
|
|
||||||
|
## Listes de jeu
|
||||||
|
|
||||||
|
- Ajouter la liste JSON dans `website/public/data/<gameId>/lists/`.
|
||||||
|
- Lancer `npm run lists:index`.
|
||||||
|
- Vérifier que `index.json` est régénéré.
|
||||||
|
- Vérifier que `nom` est présent sur chaque élément.
|
||||||
|
- Vérifier que `description` et `quantite` restent facultatifs.
|
||||||
|
- Vérifier la copie au format checklist.
|
||||||
|
- Vérifier la création directe de checklist quand une toolbox est associée.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Réutiliser les tokens existants avant d'ajouter une nouvelle couleur ou taille.
|
||||||
|
- Réutiliser les classes de boutons existantes.
|
||||||
|
- Vérifier les boutons icon, danger, primary et secondary.
|
||||||
|
- Vérifier les cartes, panels et scrollbars.
|
||||||
|
- Vérifier les états hover/focus.
|
||||||
|
- Vérifier les fenêtres de 768, 960, 1024, 1280, 1440 et 1920 px quand le changement touche la mise en page, avec une hauteur de 720 px pour les parcours compacts.
|
||||||
|
- Effectuer seulement un contrôle minimal sous 768 px : accès au contenu, sidebar utilisable et absence de débordement horizontal global.
|
||||||
|
- Sur l'accueil vide, vérifier que l'action principale ouvre la création avec tutoriel ; avec au moins une toolbox, vérifier qu'elle devient « Voir mes toolboxes ». L'action « Voir les jeux » et le rappel de création en fin de prise en main restent disponibles.
|
||||||
|
- Les tests `e2e/card-surfaces.spec.js` compilent le Sass complet et vérifient les
|
||||||
|
styles calculés des cartes NDS, home jeux et MHW en fenêtre large/compacte : fonds,
|
||||||
|
bordures, séparateurs dorés, hover sans redimensionnement, focus clavier NDS
|
||||||
|
et faces MHW. Ils ne créent pas de snapshots visuels et ne remplacent pas les
|
||||||
|
parcours des pages réelles.
|
||||||
|
- Les suites `nds-controls`, `nds-patterns`, `style-layouts` et `tool-style-states`
|
||||||
|
complètent cette couverture : tailles et états NDS, panneaux, feedbacks,
|
||||||
|
modales/drawers/toasts, scrollbars, mouvement réduit, états des outils et
|
||||||
|
masques d'icônes décodables. Les layouts home/À propos/librairie sont aussi
|
||||||
|
vérifiés sur les pages réelles en fenêtre large et demi-écran.
|
||||||
|
- `tests/static-styles.test.mjs` conserve les frontières d'architecture, les
|
||||||
|
conventions publiques et les limites de taille des fichiers, pas des preuves
|
||||||
|
de rendu basées sur la présence d'un nom de classe ou de mixin dans le Sass.
|
||||||
|
|
||||||
|
## Validation avant push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Le check doit passer avant de pousser :
|
||||||
|
|
||||||
|
- syntaxe serveur et Vite ;
|
||||||
|
- génération des index de listes ;
|
||||||
|
- tests Node ;
|
||||||
|
- build de production.
|
||||||
|
|
||||||
|
`npm run check:full` ajoute les parcours navigateur de développement puis de
|
||||||
|
production. Pour la prise en main de la home, vérifier sa présence sans toolbox,
|
||||||
|
sa disparition après création en production, son retour après suppression de la
|
||||||
|
dernière toolbox et la persistance de ces états après rechargement. Elle reste
|
||||||
|
visible en développement et sur la page À propos.
|
||||||
1246
docs/STORAGE_SCHEMA.md
Normal file
1246
docs/STORAGE_SCHEMA.md
Normal file
File diff suppressed because it is too large
Load diff
53
docs/TEST_STRATEGY.md
Normal file
53
docs/TEST_STRATEGY.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Test Strategy
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The risk-based completion boundary for audit C06 (2026-09-09), agreed on
|
||||||
|
2026-09-17, is regression coverage of important workflows, not elimination of
|
||||||
|
every source-text assertion. Beads task `sokko-g-zsd` tracks acceptance and status.
|
||||||
|
This document is a coverage reference, not a separate task backlog.
|
||||||
|
|
||||||
|
An inventory found 350 `match` / `doesNotMatch` calls across the static app,
|
||||||
|
toolbox and style tests before the final batch. This counts assertion calls,
|
||||||
|
not bugs, missing scenarios or a percentage of work remaining.
|
||||||
|
|
||||||
|
## Coverage Boundary
|
||||||
|
|
||||||
|
| Risk | Behavioral coverage |
|
||||||
|
| --- | --- |
|
||||||
|
| Data loss, startup failure, deferred media | `e2e/persistence.spec.js`, `storage-startup.spec.js`, `deferred-media.spec.js`; Node persistence queue and image action tests |
|
||||||
|
| React lifecycle, shared editing and navigation | `e2e/editor-lifecycle.spec.js`, `inline-edit.spec.js`, `form-reorder.spec.js`, `shell-behavior.spec.js`, `session-state.spec.js` |
|
||||||
|
| Shared reorder policy and module order | `e2e/grouped-reorder.spec.js`, `module-order.spec.js` |
|
||||||
|
| Toolbox creation, import/export, text exchange | `e2e/toolbox.spec.js`, `text-exchange.spec.js`; Node toolbox module tests |
|
||||||
|
| Links: creation, copy, category removal preserving links, deletion and reload | `e2e/basic-tools.spec.js` |
|
||||||
|
| Counters: creation, signed values, reset, deletion and reload | `e2e/basic-tools.spec.js` |
|
||||||
|
| Checklist: quantities, clamping, completed item/category visibility and persistence | `e2e/basic-tools.spec.js` |
|
||||||
|
| Images: gallery, detail navigation, keyboard controls and focus restoration | `e2e/basic-tools.spec.js` |
|
||||||
|
| Guide checklists: persistent association, page controls, drawer scope and live completion | `e2e/guide-checklist.spec.js` |
|
||||||
|
| Mémos : bande fixe, portée par route et persistance du toggle | `e2e/memos-ticker.spec.js` |
|
||||||
|
| Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` |
|
||||||
|
| Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` |
|
||||||
|
| Production onboarding | `e2e/production/onboarding.spec.js` |
|
||||||
|
|
||||||
|
These suites cover selected risks, not every permutation of every tool.
|
||||||
|
Public formats, product vocabulary, editorial validation, security restrictions
|
||||||
|
and deliberate architecture constraints retain static or pure-function tests.
|
||||||
|
Implementation-name assertions can be replaced when their owning code changes;
|
||||||
|
their mere presence is not a functional defect.
|
||||||
|
|
||||||
|
## Optional Extensions
|
||||||
|
|
||||||
|
Exhaustive advanced tool variants (calendar recurrence operations, goal graph
|
||||||
|
combinations, equipment and combo permutations), every text-exchange UI adapter,
|
||||||
|
fine calculator geometry and wholesale conversion of remaining implementation
|
||||||
|
assertions are outside this closure boundary. They are candidates when related
|
||||||
|
features change or a concrete regression demonstrates missing coverage, not
|
||||||
|
automatic follow-on batches required to finish this audit.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run targeted tests while implementing a grouped change, then `npm run check:full`
|
||||||
|
as the final gate: lint, Node tests, build, development E2E and production E2E.
|
||||||
|
Review the diff and report actual failures or limits before closing the task.
|
||||||
|
Do not introduce visual snapshots solely to prove that a page loads.
|
||||||
|
Keep detailed command logs outside tracked source and report concise results.
|
||||||
441
e2e/aniimo.spec.js
Normal file
441
e2e/aniimo.spec.js
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
// Rôle : vérifie les parcours Aniimo, les variantes, les cartes et les seuils au clavier.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
const route = "/#/games/aniimo/aniimos";
|
||||||
|
const cards = (page) => page.locator(".aniimo-result");
|
||||||
|
const filterTrigger = (page, name) => page.getByRole("button", { name: new RegExp(`^${name}`) });
|
||||||
|
const filterMenu = (page, name) => page.getByRole("dialog", { name: new RegExp(`^${name}`) });
|
||||||
|
async function openFilter(page, name) {
|
||||||
|
const trigger = filterTrigger(page, name);
|
||||||
|
if (await trigger.getAttribute("aria-expanded") !== "true") await trigger.click();
|
||||||
|
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||||
|
return filterMenu(page, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("role and element menus use two columns and highlight their condition", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(route);
|
||||||
|
const roles = await openFilter(page, "Rôles");
|
||||||
|
await expect(roles).toHaveAttribute("data-columns", "2");
|
||||||
|
expect(await roles.locator(".nds-filter-menu-options").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
const elements = await openFilter(page, "Éléments");
|
||||||
|
await expect(elements).toHaveAttribute("data-columns", "2");
|
||||||
|
expect(await elements.locator(".nds-filter-menu-options").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
|
||||||
|
expect((await elements.boundingBox()).width).toBeLessThanOrEqual(320);
|
||||||
|
expect((await elements.locator(".nds-filter-menu-option").first().boundingBox()).width).toBeLessThan(160);
|
||||||
|
const condition = elements.locator(".nds-filter-logic");
|
||||||
|
await expect(condition).toHaveCSS("border-top-style", "solid");
|
||||||
|
await expect(condition).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [1280, 390]) {
|
||||||
|
test(`Prismana filter combines with search and survives grouping and navigation at ${width}px`, async ({ page }) => {
|
||||||
|
test.setTimeout(45000);
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.goto(route);
|
||||||
|
const prismana = page.getByRole("button", { name: "Prismana uniquement", exact: true });
|
||||||
|
await expect(prismana).toBeVisible({ timeout: 30000 });
|
||||||
|
await expect(prismana).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await prismana.focus();
|
||||||
|
await prismana.press("Space");
|
||||||
|
await expect(cards(page)).toHaveCount(27);
|
||||||
|
await expect(prismana).toHaveText("Prismana");
|
||||||
|
await expect(prismana).toHaveClass(/nds-filter-option/);
|
||||||
|
const prismanaBadge = page.getByRole("button", { name: "Retirer le filtre Prismana", exact: true });
|
||||||
|
await expect(prismanaBadge).toBeVisible();
|
||||||
|
await prismanaBadge.click();
|
||||||
|
await expect(prismana).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await prismana.click();
|
||||||
|
await expect(page.locator('.aniimo-result:not([data-form="prismana_form"])')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("tablist")).toHaveCount(0);
|
||||||
|
expect(await page.locator('img[src="/static/icons/prismana.svg"]').evaluate((image) => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||||
|
await page.getByRole("searchbox").fill("iris");
|
||||||
|
await expect(cards(page)).toHaveCount(3);
|
||||||
|
await page.locator('.game-breadcrumb a[href="#/games/aniimo"]').click();
|
||||||
|
await page.locator('a[href="#/games/aniimo/aniimos"]').click();
|
||||||
|
await expect(prismana).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(cards(page)).toHaveCount(3);
|
||||||
|
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
||||||
|
await expect(cards(page)).toHaveCount(3);
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
|
||||||
|
await expect(prismana).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(cards(page)).toHaveCount(208);
|
||||||
|
await expect(page.getByRole("button", { name: "Regrouper les variantes", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Aniimo matches MHW card motion, surfaces and filter controls", async ({ page }) => {
|
||||||
|
test.setTimeout(45000);
|
||||||
|
async function styles(locator, properties) {
|
||||||
|
return locator.evaluate((element, keys) => {
|
||||||
|
const css = globalThis.getComputedStyle(element);
|
||||||
|
return Object.fromEntries(keys.map((key) => [key, css.getPropertyValue(key)]));
|
||||||
|
}, properties);
|
||||||
|
}
|
||||||
|
const motionProperties = ["transition-duration", "transition-timing-function", "transform-style"];
|
||||||
|
const faceProperties = ["background-image", "border-radius", "backface-visibility"];
|
||||||
|
const indicatorProperties = ["width", "height", "background-color", "opacity"];
|
||||||
|
const filterProperties = ["background-color", "border-radius", "min-height", "padding"];
|
||||||
|
await page.goto("/#/games/mhwilds");
|
||||||
|
await expect(page.locator(".game-home-card").first()).toBeVisible();
|
||||||
|
const homeCard = await page.locator(".game-home-card").first().boundingBox();
|
||||||
|
await page.goto("/#/games/aniimo");
|
||||||
|
await expect(page.locator(".aniimo-home-card")).toBeVisible();
|
||||||
|
const aniimoHomeCard = await page.locator(".aniimo-home-card").boundingBox();
|
||||||
|
expect(aniimoHomeCard.width).toBeCloseTo(homeCard.width, 1);
|
||||||
|
expect(aniimoHomeCard.height).toBeCloseTo(homeCard.height, 1);
|
||||||
|
await page.goto("/#/games/mhwilds/monsters");
|
||||||
|
await expect(page.locator(".monster-card").first()).toBeVisible({ timeout: 15000 });
|
||||||
|
const motion = await styles(page.locator(".mhwilds-card-inner").first(), motionProperties);
|
||||||
|
const face = await styles(page.locator(".mhwilds-card-front").first(), faceProperties);
|
||||||
|
const indicator = await styles(page.locator(".mhwilds-flip-indicator").first(), indicatorProperties);
|
||||||
|
const filter = await styles(page.locator(".filter-logic"), filterProperties);
|
||||||
|
await page.goto(route);
|
||||||
|
await openFilter(page, "Éléments");
|
||||||
|
const card = cards(page).first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
expect(await styles(card.locator(".aniimo-card-inner"), motionProperties)).toEqual(motion);
|
||||||
|
expect(await styles(card.locator(".aniimo-front"), faceProperties)).toEqual(face);
|
||||||
|
expect(await styles(card.locator(".aniimo-front .aniimo-flip"), indicatorProperties)).toEqual(indicator);
|
||||||
|
expect(await styles(page.locator(".filter-logic").first(), filterProperties)).toEqual(filter);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(card.locator(".aniimo-face .aniimo-flip")).toHaveCount(2);
|
||||||
|
await expect(card.locator(".aniimo-card > .aniimo-flip")).toHaveCount(0);
|
||||||
|
await card.getByRole("button", { name: /Afficher les capacités/ }).press("Enter");
|
||||||
|
await expect(card.getByRole("button", { name: /Afficher le portrait/ })).toBeFocused();
|
||||||
|
await expect(card.locator(".aniimo-front")).toHaveAttribute("inert", "");
|
||||||
|
await card.getByRole("button", { name: /Afficher le portrait/ }).press("Enter");
|
||||||
|
await expect(card.getByRole("button", { name: /Afficher les capacités/ })).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("catalogue, overview and deferred Aniimo loading preserve filters across navigation", async ({ page }) => {
|
||||||
|
const requests = [];
|
||||||
|
page.on("request", (request) => { if (request.url().includes("/data/aniimo/")) requests.push(request.url()); });
|
||||||
|
await page.goto("/#/games");
|
||||||
|
await expect(page.getByRole("heading", { name: "Jeux disponibles" })).toBeVisible();
|
||||||
|
expect(requests).toHaveLength(0);
|
||||||
|
await page.locator('a.game-card-cover[href="#/games/aniimo"]').click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Aniimo", exact: true })).toBeVisible();
|
||||||
|
const cover = page.locator('.aniimo-home-card img');
|
||||||
|
await expect(cover).toHaveAttribute("src", "/static/img/aniimo/emberpup__basic_form.png");
|
||||||
|
await expect(cover).toHaveCSS("object-fit", "cover");
|
||||||
|
await expect(cover).toHaveCSS("object-position", "50% 38%");
|
||||||
|
await expect(cover).toHaveCSS("height", "120px");
|
||||||
|
await page.locator('a[href="#/games/aniimo/aniimos"]').click();
|
||||||
|
await expect(cards(page)).toHaveCount(88);
|
||||||
|
await expect(page.getByRole("status")).toHaveText("88 / 88 Aniimos · 208 / 208 variantes");
|
||||||
|
const switchLabel = page.getByRole("button", { name: "Regrouper les variantes", exact: true }).locator("span:not(.ui-icon)");
|
||||||
|
expect(await switchLabel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
await page.getByRole("searchbox", { name: "Nom" }).fill("EMBERPUP");
|
||||||
|
await expect(cards(page)).toHaveCount(1);
|
||||||
|
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
||||||
|
await expect(cards(page)).toHaveCount(3);
|
||||||
|
await page.locator('.game-breadcrumb a[href="#/games/aniimo"]').click();
|
||||||
|
await page.locator('a[href="#/games/aniimo/aniimos"]').click();
|
||||||
|
await expect(cards(page)).toHaveCount(3);
|
||||||
|
await expect(page.getByRole("searchbox")).toHaveValue("EMBERPUP");
|
||||||
|
expect(requests).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Aniimo exposes its regions list", async ({ page }) => {
|
||||||
|
await page.goto("/#/games/aniimo/lists");
|
||||||
|
await expect(page.getByRole("heading", { name: "Listes", exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator("h2", { hasText: "Regions" })).toBeVisible();
|
||||||
|
await expect(page.getByText("14 éléments", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Champs de Cumulaine", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tabs, flip, matching variants and reset remain independent", async ({ page }) => {
|
||||||
|
await page.goto(route);
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
const card = cards(page).first();
|
||||||
|
await expect(card.getByRole("tab")).toHaveCount(3);
|
||||||
|
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
|
||||||
|
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
|
||||||
|
await card.getByRole("tab", { name: "Hautes-plaines", exact: true }).click();
|
||||||
|
await expect(card).toHaveAttribute("data-form", "highlands");
|
||||||
|
await expect(card.getByRole("button", { name: /Afficher le portrait/ })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
|
||||||
|
await expect(card.locator(".aniimo-back .aniimo-attributes").first()).toContainText("Roche");
|
||||||
|
await card.getByRole("tab", { name: "Forme de base", exact: true }).focus();
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
|
||||||
|
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
|
||||||
|
const elements = await openFilter(page, "Éléments");
|
||||||
|
await elements.getByRole("checkbox", { name: "Roche", exact: true }).check();
|
||||||
|
await expect(card.getByRole("tab")).toHaveCount(0);
|
||||||
|
await expect(card).toHaveAttribute("data-form", "highlands");
|
||||||
|
await elements.getByRole("checkbox", { name: "Eau", exact: true }).check();
|
||||||
|
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
||||||
|
await elements.getByRole("button", { name: "OU", exact: true }).click();
|
||||||
|
await expect(cards(page)).toHaveCount(1);
|
||||||
|
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
||||||
|
await expect(cards(page)).toHaveCount(1);
|
||||||
|
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
|
||||||
|
await expect(cards(page)).toHaveCount(208);
|
||||||
|
await expect(page.getByRole("button", { name: "Regrouper les variantes", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ability levels use independent native ranges and retain name constraints", async ({ page }) => {
|
||||||
|
await page.goto(route);
|
||||||
|
const abilities = await openFilter(page, "Compétences de logis");
|
||||||
|
const carry = page.locator("#aniimo-minimum-carry");
|
||||||
|
const crafting = page.locator("#aniimo-minimum-crafting");
|
||||||
|
const abilityColumns = abilities.locator(".aniimo-ability-column");
|
||||||
|
await expect(abilityColumns).toHaveCount(2);
|
||||||
|
await expect(abilityColumns.nth(0)).toHaveAttribute("aria-label", "Éléments");
|
||||||
|
await expect(abilityColumns.nth(1)).toHaveAttribute("aria-label", "Actions");
|
||||||
|
await expect(abilities.locator(".aniimo-ability-column-title")).toHaveCount(0);
|
||||||
|
await expect(abilityColumns.nth(0)).toHaveCSS("border-right-style", "solid");
|
||||||
|
expect((await abilityColumns.nth(0).boundingBox()).height).toBeGreaterThan((await abilityColumns.nth(1).boundingBox()).height);
|
||||||
|
await expect(abilityColumns.nth(0).getByRole("slider", { name: /Eau/ })).toBeVisible();
|
||||||
|
await expect(abilityColumns.nth(1).getByRole("slider", { name: /Artisanat/ })).toBeVisible();
|
||||||
|
expect(await abilities.locator(".aniimo-ability-options").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
|
||||||
|
expect((await abilities.boundingBox()).width).toBeGreaterThanOrEqual(600);
|
||||||
|
await expect(abilities.locator(".aniimo-ability-options")).toHaveCSS("overflow-y", "visible");
|
||||||
|
await expect(abilities.getByRole("checkbox")).toHaveCount(0);
|
||||||
|
await expect(abilities.getByRole("button", { name: "OU", exact: true })).toHaveCount(0);
|
||||||
|
const minimumLevel = abilities.getByRole("button", { name: "Niveau minimum", exact: true });
|
||||||
|
const exactLevel = abilities.getByRole("button", { name: "Niveau exact", exact: true });
|
||||||
|
await expect(minimumLevel).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(exactLevel).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(carry).toHaveAccessibleName("Porter — Niveau minimum");
|
||||||
|
await expect(crafting).toHaveAccessibleName("Artisanat — Niveau minimum");
|
||||||
|
await expect(carry).toHaveValue("0");
|
||||||
|
await expect(carry).toHaveAttribute("min", "0");
|
||||||
|
await carry.focus();
|
||||||
|
await carry.press("ArrowRight");
|
||||||
|
await carry.press("ArrowRight");
|
||||||
|
await carry.press("ArrowRight");
|
||||||
|
await expect(carry).toHaveValue("3");
|
||||||
|
await expect(page.getByRole("button", { name: "Retirer le filtre Porter ≥ 3", exact: true })).toBeVisible();
|
||||||
|
await expect(crafting).toHaveValue("0");
|
||||||
|
await crafting.focus();
|
||||||
|
await crafting.press("ArrowRight");
|
||||||
|
await crafting.press("ArrowRight");
|
||||||
|
await expect(crafting).toHaveValue("2");
|
||||||
|
await expect(crafting).toHaveCSS("outline-style", "solid");
|
||||||
|
await exactLevel.click();
|
||||||
|
await expect(exactLevel).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(minimumLevel).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(carry).toHaveAccessibleName("Porter — Niveau exact");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
const elements = await openFilter(page, "Éléments");
|
||||||
|
await expect(elements.getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
const reopenedAbilities = await openFilter(page, "Compétences de logis");
|
||||||
|
const reopenedCarry = reopenedAbilities.getByRole("slider", { name: "Porter — Niveau exact" });
|
||||||
|
const reopenedCrafting = reopenedAbilities.getByRole("slider", { name: "Artisanat — Niveau exact" });
|
||||||
|
await reopenedCarry.press("Home");
|
||||||
|
await reopenedCrafting.press("Home");
|
||||||
|
await expect(reopenedCarry).toHaveAttribute("aria-valuetext", "Sans filtre");
|
||||||
|
await expect(cards(page)).toHaveCount(88);
|
||||||
|
await page.getByRole("searchbox").fill("does-not-exist");
|
||||||
|
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
|
||||||
|
const resetAbilities = await openFilter(page, "Compétences de logis");
|
||||||
|
await expect(resetAbilities.getByRole("button", { name: "Niveau minimum", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(resetAbilities.getByRole("button", { name: "Niveau exact", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("seven tabs support keyboard navigation and fall back after filtering", async ({ page }) => {
|
||||||
|
await page.goto(route);
|
||||||
|
await page.getByRole("searchbox").fill("iris");
|
||||||
|
const card = cards(page).filter({ has: page.getByRole("heading", { name: "Iris", exact: true }) });
|
||||||
|
await expect(card.getByRole("tab")).toHaveCount(7);
|
||||||
|
const plateau = card.getByRole("tab", { name: "Forme des plateaux", exact: true });
|
||||||
|
await expect(plateau).toHaveText("PLA");
|
||||||
|
await plateau.hover();
|
||||||
|
await expect(page.getByRole("tooltip", { name: "Forme des plateaux", exact: true })).toBeVisible();
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await plateau.focus();
|
||||||
|
await expect(page.getByRole("tooltip", { name: "Forme des plateaux", exact: true })).toBeVisible();
|
||||||
|
const firstTab = await card.getByRole("tab").first().boundingBox();
|
||||||
|
const secondTab = await card.getByRole("tab").nth(1).boundingBox();
|
||||||
|
expect(secondTab.x).toBeGreaterThanOrEqual(firstTab.x + firstTab.width);
|
||||||
|
expect(await card.getByRole("tab").nth(1).evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
await card.getByRole("tab").first().focus();
|
||||||
|
await page.keyboard.press("End");
|
||||||
|
await expect(card).toHaveAttribute("data-form", "prismana_form");
|
||||||
|
await page.keyboard.press("Home");
|
||||||
|
await expect(card).toHaveAttribute("data-form", "basic_form");
|
||||||
|
await card.getByRole("tab", { name: "Forme prismana", exact: true }).click();
|
||||||
|
await expect(card).toHaveAttribute("data-form", "prismana_form");
|
||||||
|
const elements = await openFilter(page, "Éléments");
|
||||||
|
await elements.getByRole("checkbox", { name: "Herbe", exact: true }).check();
|
||||||
|
await expect(card).toHaveAttribute("data-form", "prismana_form");
|
||||||
|
await elements.getByRole("checkbox", { name: "Herbe", exact: true }).uncheck();
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
await expect(cards(page)).toHaveAttribute("data-form", "basic_form");
|
||||||
|
const reopenedElements = await openFilter(page, "Éléments");
|
||||||
|
await reopenedElements.getByRole("checkbox", { name: "Roche", exact: true }).check();
|
||||||
|
await expect(cards(page)).toHaveAttribute("data-form", "highlands");
|
||||||
|
await reopenedElements.getByRole("checkbox", { name: "Roche", exact: true }).uncheck();
|
||||||
|
await expect(cards(page)).toHaveAttribute("data-form", "highlands");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [1280, 390]) {
|
||||||
|
test(`uniform cards, visible variant tabs and scrollable backs at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 844 });
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.goto(route);
|
||||||
|
await expect(cards(page)).toHaveCount(88);
|
||||||
|
const sizes = await page.locator(".aniimo-card").evaluateAll((elements) => elements.map((element) => {
|
||||||
|
const { width, height } = element.getBoundingClientRect();
|
||||||
|
return { width, height };
|
||||||
|
}));
|
||||||
|
expect(Math.max(...sizes.map((size) => size.width)) - Math.min(...sizes.map((size) => size.width))).toBeLessThan(1);
|
||||||
|
expect(Math.max(...sizes.map((size) => size.height)) - Math.min(...sizes.map((size) => size.height))).toBeLessThan(1);
|
||||||
|
await expect(page.locator(".aniimo-front .aniimo-element-icons").first()).toHaveText("");
|
||||||
|
await expect(page.locator(".aniimo-front .aniimo-element-icons img").first()).toHaveAttribute("alt", "Feu");
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
const longFormCard = cards(page).first();
|
||||||
|
const baseHeight = (await longFormCard.boundingBox()).height;
|
||||||
|
await longFormCard.getByRole("tab", { name: "Forme des montagnes boisées", exact: true }).click();
|
||||||
|
const form = longFormCard.locator(".aniimo-front .aniimo-form");
|
||||||
|
await expect(form).toHaveCSS("white-space", "normal");
|
||||||
|
expect(await form.evaluate((element) => element.scrollHeight <= element.clientHeight)).toBe(true);
|
||||||
|
expect((await longFormCard.boundingBox()).height).toBeCloseTo(baseHeight, 1);
|
||||||
|
await longFormCard.getByRole("tab", { name: "Forme de base", exact: true }).click();
|
||||||
|
await page.getByRole("searchbox").fill("");
|
||||||
|
const icons = page.locator(".aniimo-front .aniimo-element-icons").first();
|
||||||
|
const rowBox = await icons.boundingBox();
|
||||||
|
const iconBox = await icons.locator("img").boundingBox();
|
||||||
|
expect(iconBox.x + iconBox.width / 2).toBeCloseTo(rowBox.x + rowBox.width / 2, 1);
|
||||||
|
|
||||||
|
await page.getByRole("searchbox").fill("iris");
|
||||||
|
for (const name of ["Iris", "Valsiris"]) {
|
||||||
|
const card = cards(page).filter({ has: page.getByRole("heading", { name, exact: true }) });
|
||||||
|
const tabs = card.getByRole("tablist");
|
||||||
|
await expect(card.getByRole("tab")).toHaveCount(7);
|
||||||
|
await expect(card.getByRole("tab").last()).toHaveAccessibleName("Forme prismana");
|
||||||
|
expect(await tabs.evaluate((element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return element.scrollWidth <= element.clientWidth && [...element.children].every((tab) => {
|
||||||
|
const bounds = tab.getBoundingClientRect();
|
||||||
|
return bounds.left >= box.left && bounds.right <= box.right && tab.scrollWidth <= tab.clientWidth;
|
||||||
|
});
|
||||||
|
})).toBe(true);
|
||||||
|
await expect(card.getByRole("tab").last()).toHaveCSS("background-image", /linear-gradient/);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole("searchbox").fill("fenmane");
|
||||||
|
const card = cards(page).first();
|
||||||
|
const before = await card.locator(".aniimo-card").boundingBox();
|
||||||
|
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
|
||||||
|
await card.getByRole("tab", { name: "Forme prismana", exact: true }).click();
|
||||||
|
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
|
||||||
|
const after = await card.locator(".aniimo-card").boundingBox();
|
||||||
|
expect(after.height).toBeCloseTo(before.height, 1);
|
||||||
|
expect(after.width).toBeCloseTo(before.width, 1);
|
||||||
|
const details = card.locator(".aniimo-back-details");
|
||||||
|
await expect(details).toHaveCSS("overflow-y", "auto");
|
||||||
|
await expect(details.locator(".aniimo-abilities li")).toHaveCount(4);
|
||||||
|
if (width === 1280) {
|
||||||
|
expect(await details.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||||
|
await details.focus();
|
||||||
|
await details.press("End");
|
||||||
|
await expect.poll(() => details.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
await card.getByRole("button", { name: /Afficher le portrait/ }).click();
|
||||||
|
expect((await card.locator(".aniimo-card").boundingBox()).height).toBeCloseTo(before.height, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("portrait geometry, stable faces and mobile layout", async ({ page }) => {
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto(route);
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
const card = cards(page).first();
|
||||||
|
await card.scrollIntoViewIfNeeded();
|
||||||
|
const portrait = card.locator(".aniimo-portrait img");
|
||||||
|
await expect(portrait).toHaveCSS("object-fit", "cover");
|
||||||
|
await expect(portrait).toHaveCSS("object-position", "50% 50%");
|
||||||
|
await expect(portrait).toHaveJSProperty("naturalWidth", 512);
|
||||||
|
const size = await portrait.boundingBox();
|
||||||
|
expect(size.height).toBeLessThanOrEqual(250);
|
||||||
|
expect(size.height).toBeGreaterThan(240);
|
||||||
|
const before = await card.boundingBox();
|
||||||
|
expect(before.height).toBeLessThan(520);
|
||||||
|
expect(before.width).toBeLessThanOrEqual(280);
|
||||||
|
await card.getByRole("button", { name: /Afficher les capacités/ }).press("Enter");
|
||||||
|
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
|
||||||
|
expect((await card.boundingBox()).height).toBeCloseTo(before.height, 1);
|
||||||
|
const summary = card.locator(".aniimo-back-summary > div");
|
||||||
|
const elementBox = await summary.nth(0).boundingBox();
|
||||||
|
const roleBox = await summary.nth(1).boundingBox();
|
||||||
|
expect(elementBox.y).toBeCloseTo(roleBox.y, 1);
|
||||||
|
expect(elementBox.x + elementBox.width).toBeLessThan(roleBox.x);
|
||||||
|
await expect(card.getByRole("progressbar")).toHaveCount(6);
|
||||||
|
await expect(card.getByRole("progressbar", { name: "PV", exact: true })).toHaveAttribute("aria-valuenow", "67");
|
||||||
|
await expect(card.getByRole("progressbar", { name: "PV", exact: true })).toHaveAttribute("aria-valuemax", "130");
|
||||||
|
await expect(card.getByRole("progressbar", { name: "PV", exact: true })).toHaveCSS("height", "4px");
|
||||||
|
await expect(card.locator(".aniimo-stat-value")).toHaveText(["67", "90", "60", "57", "40", "68"]);
|
||||||
|
await expect(card.locator(".aniimo-stat-total")).toHaveText("Total382");
|
||||||
|
expect(await card.locator(".aniimo-card-inner").evaluate((element) => Number.parseFloat(globalThis.getComputedStyle(element).transitionDuration))).toBeLessThan(0.001);
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop grid uses the width freed by horizontal filters and shows complete stats", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||||
|
await page.goto(route);
|
||||||
|
await expect(cards(page)).toHaveCount(88);
|
||||||
|
const columns = await page.locator(".aniimo-grid").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length);
|
||||||
|
expect(columns).toBe(6);
|
||||||
|
await expect(page.locator(".ui-icon-multiple")).toHaveCSS("mask-image", /multiples\.svg/);
|
||||||
|
const card = cards(page).first();
|
||||||
|
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
|
||||||
|
await card.locator(".aniimo-back-details").evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
||||||
|
await expect(card.getByRole("progressbar")).toHaveCount(6);
|
||||||
|
await page.screenshot({ path: test.info().outputPath("aniimo-stats-desktop.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advanced filter menus retain selections, counts and keyboard closing", async ({ page }) => {
|
||||||
|
await page.goto(route);
|
||||||
|
const roles = await openFilter(page, "Rôles");
|
||||||
|
await expect(roles.getByRole("checkbox", { name: "SOIN", exact: true })).toHaveCount(1);
|
||||||
|
await expect(roles.getByRole("checkbox", { name: "SOUTIEN", exact: true })).toHaveCount(1);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(filterTrigger(page, "Rôles")).toBeFocused();
|
||||||
|
const elements = await openFilter(page, "Éléments");
|
||||||
|
await elements.getByRole("checkbox", { name: "Roche", exact: true }).check();
|
||||||
|
const results = await page.getByRole("status").textContent();
|
||||||
|
await expect(filterTrigger(page, "Éléments").locator(".nds-filter-count")).toHaveText("1");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(elements).toBeHidden();
|
||||||
|
await expect(page.getByRole("status")).toHaveText(results);
|
||||||
|
const reopenedElements = await openFilter(page, "Éléments");
|
||||||
|
await expect(reopenedElements.getByRole("checkbox", { name: "Roche", exact: true })).toBeChecked();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
const abilities = await openFilter(page, "Compétences de logis");
|
||||||
|
const groups = await abilities.locator(".aniimo-ability-filter").evaluateAll((rows) => rows.map((row) => row.dataset.abilityGroup));
|
||||||
|
expect(new Set(groups)).toEqual(new Set(["elements", "actions"]));
|
||||||
|
expect(groups).toEqual([...groups.filter((group) => group === "elements"), ...groups.filter((group) => group === "actions")]);
|
||||||
|
const slider = abilities.getByRole("slider", { name: "Porter — Niveau minimum" });
|
||||||
|
await slider.focus();
|
||||||
|
await slider.press("End");
|
||||||
|
await expect(filterTrigger(page, "Compétences de logis").locator(".nds-filter-count")).toHaveText("1");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(slider).toBeHidden();
|
||||||
|
const reopenedAbilities = await openFilter(page, "Compétences de logis");
|
||||||
|
await expect(reopenedAbilities.getByRole("slider", { name: "Porter — Niveau minimum" })).toHaveValue("4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending and failed data requests show loading and error states", async ({ page }) => {
|
||||||
|
let releaseRequests;
|
||||||
|
const pending = new Promise((resolve) => { releaseRequests = resolve; });
|
||||||
|
await page.route("**/data/aniimo/aniimos.json", async (route) => {
|
||||||
|
await pending;
|
||||||
|
await route.fulfill({ status: 503, body: "unavailable" });
|
||||||
|
});
|
||||||
|
await page.goto(route);
|
||||||
|
await expect(page.getByRole("heading", { name: "Chargement des Aniimos…" })).toBeVisible();
|
||||||
|
releaseRequests();
|
||||||
|
await expect(page.getByRole("heading", { name: "Impossible de charger Aniimo" })).toBeVisible();
|
||||||
|
});
|
||||||
119
e2e/app-data.spec.js
Normal file
119
e2e/app-data.spec.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// Rôle : couvre chargement différé, cache de navigation, erreurs et annulations des données publiques.
|
||||||
|
/* global window, document */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/app-data-probe.js", import.meta.url))}`;
|
||||||
|
const siteUrl = "/data/site.json";
|
||||||
|
const gamesUrl = "/data/games.json";
|
||||||
|
const diabloUrl = "/data/diablo4/affixes_types.json";
|
||||||
|
const mhwBase = "/data/mhwilds/";
|
||||||
|
|
||||||
|
async function setup(page, route = "/") {
|
||||||
|
await page.route("**/__app-data-probe", (request) => request.fulfill({
|
||||||
|
contentType: "text/html", body: '<!doctype html><div id="probe"></div>'
|
||||||
|
}));
|
||||||
|
await page.goto("/__app-data-probe");
|
||||||
|
await page.evaluate(async ({ url, route }) => {
|
||||||
|
const { mountAppDataProbe } = await import(url);
|
||||||
|
window.dataProbe = mountAppDataProbe(document.getElementById("probe"), route);
|
||||||
|
}, { url: probeUrl, route });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function state(page) {
|
||||||
|
return JSON.parse(await page.locator("#data-state").textContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respond(page, url, payload, status = 200, aborted = false) {
|
||||||
|
await page.evaluate(({ url, payload, status, aborted }) => {
|
||||||
|
window.dataProbe.respond(url, payload, status, aborted);
|
||||||
|
}, { url, payload, status, aborted });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("game data loads only on demand and remains cached through navigation", async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
expect((await state(page)).mhwilds.loading).toBe(false);
|
||||||
|
expect(await page.evaluate(() => window.dataProbe.requests().map((request) => request.url)))
|
||||||
|
.toEqual([siteUrl, gamesUrl, siteUrl, gamesUrl]);
|
||||||
|
await respond(page, siteUrl, { title: "Current" });
|
||||||
|
await respond(page, gamesUrl, { games: [{ id: "mhwilds" }] });
|
||||||
|
await expect.poll(async () => (await state(page)).siteContent?.title).toBe("Current");
|
||||||
|
await respond(page, siteUrl, { title: "Obsolete" }, 200, true);
|
||||||
|
await respond(page, gamesUrl, {}, 503, true);
|
||||||
|
|
||||||
|
const snapshots = await page.evaluate(() => {
|
||||||
|
window.dataProbe.navigate("/games/mhwilds");
|
||||||
|
return window.dataProbe.snapshots;
|
||||||
|
});
|
||||||
|
expect(snapshots.length).toBeGreaterThan(0);
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
expect(snapshot.mhwilds.loading).toBe(true);
|
||||||
|
expect(snapshot.mhwilds.loaded).toBe(false);
|
||||||
|
expect(snapshot.siteContent.title).toBe("Current");
|
||||||
|
expect(snapshot.gamesError).toBe("");
|
||||||
|
}
|
||||||
|
await respond(page, `${mhwBase}monsters.json`, { monsters: [{ id: "rey-dau" }] });
|
||||||
|
await respond(page, `${mhwBase}endemic_life.json`, {});
|
||||||
|
await respond(page, `${mhwBase}lists/index.json`, { lists: [{ id: "farm", file: "farm.json" }] });
|
||||||
|
await respond(page, `${mhwBase}i18n/fr.json`, { hello: "Bonjour" });
|
||||||
|
await expect.poll(() => page.evaluate(() => window.dataProbe.requests().some((request) => request.url.endsWith("/farm.json")))).toBe(true);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.dataProbe.navigate("/games/diablo4");
|
||||||
|
window.dataProbe.navigate("/");
|
||||||
|
});
|
||||||
|
await respond(page, `${mhwBase}lists/farm.json`, { titre: "Farm", items: [{ nom: "Potion", quantite: 2 }] });
|
||||||
|
await respond(page, diabloUrl, { affixes: [{ id: "damage" }], categories: [{ id: "attack" }] });
|
||||||
|
await expect.poll(async () => (await state(page)).mhwilds.loaded).toBe(true);
|
||||||
|
await expect.poll(async () => (await state(page)).diablo4.loaded).toBe(true);
|
||||||
|
const loaded = await state(page);
|
||||||
|
expect(loaded.mhwilds.lists[0].categories[0].items[0].quantity).toBe(2);
|
||||||
|
expect(loaded.diablo4.categoryMap.attack).toEqual({ id: "attack" });
|
||||||
|
const count = await page.evaluate(() => window.dataProbe.requests().length);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.dataProbe.navigate("/games/mhwilds/monsters");
|
||||||
|
window.dataProbe.navigate("/games/diablo4");
|
||||||
|
});
|
||||||
|
expect(await page.evaluate(() => window.dataProbe.requests().length)).toBe(count);
|
||||||
|
expect((await state(page)).mhwilds.loading).toBe(false);
|
||||||
|
await page.evaluate(() => window.dataProbe.unmount());
|
||||||
|
expect(await page.evaluate(() => window.dataProbe.requests().every((request) => request.aborted))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("StrictMode discards obsolete game failures and preserves current editorial errors", async ({ page }) => {
|
||||||
|
await setup(page, "/games/diablo4");
|
||||||
|
const requests = await page.evaluate(() => window.dataProbe.requests().filter((request) => request.url.includes("diablo4")));
|
||||||
|
expect(requests.map((request) => request.aborted)).toEqual([true, false]);
|
||||||
|
expect((await state(page)).diablo4.loading).toBe(true);
|
||||||
|
await respond(page, diabloUrl, { affixes: [{ id: "current" }] });
|
||||||
|
await expect.poll(async () => (await state(page)).diablo4.loaded).toBe(true);
|
||||||
|
await respond(page, diabloUrl, {}, 503, true);
|
||||||
|
await respond(page, siteUrl, {}, 503);
|
||||||
|
await respond(page, gamesUrl, { games: [] });
|
||||||
|
await expect.poll(async () => (await state(page)).siteContentError).toBe("Impossible de charger le contenu du site.");
|
||||||
|
expect((await state(page)).diablo4.error).toBe("");
|
||||||
|
expect((await state(page)).diablo4.affixes).toEqual([{ id: "current" }]);
|
||||||
|
await page.evaluate(() => window.dataProbe.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed game loading stops and pending list requests are aborted on unmount", async ({ page }) => {
|
||||||
|
await setup(page, "/games/diablo4");
|
||||||
|
await respond(page, diabloUrl, {}, 503);
|
||||||
|
await expect.poll(async () => (await state(page)).diablo4.error).toBe("Impossible de charger les données Diablo IV.");
|
||||||
|
expect((await state(page)).diablo4).toMatchObject({ loaded: true, loading: false });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.dataProbe.navigate("/");
|
||||||
|
window.dataProbe.navigate("/games/diablo4");
|
||||||
|
window.dataProbe.navigate("/games/mhwilds");
|
||||||
|
});
|
||||||
|
expect(await page.evaluate(() => window.dataProbe.requests().filter((request) => request.url.includes("diablo4")).length)).toBe(2);
|
||||||
|
await respond(page, `${mhwBase}monsters.json`, {});
|
||||||
|
await respond(page, `${mhwBase}endemic_life.json`, {});
|
||||||
|
await respond(page, `${mhwBase}lists/index.json`, { lists: [{ file: "pending.json" }] });
|
||||||
|
await respond(page, `${mhwBase}i18n/fr.json`, {});
|
||||||
|
await expect.poll(() => page.evaluate(() => window.dataProbe.requests().some((request) => request.url.endsWith("/pending.json")))).toBe(true);
|
||||||
|
await page.evaluate(() => window.dataProbe.unmount());
|
||||||
|
expect(await page.evaluate(() => window.dataProbe.requests().every((request) => request.aborted))).toBe(true);
|
||||||
|
await respond(page, `${mhwBase}lists/pending.json`, {}, 503, true);
|
||||||
|
await respond(page, siteUrl, {}, 503, true);
|
||||||
|
await respond(page, gamesUrl, {}, 503, true);
|
||||||
|
});
|
||||||
266
e2e/basic-tools.spec.js
Normal file
266
e2e/basic-tools.spec.js
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
// Rôle : couvre les parcours persistants des liens, compteurs, checklists et galeries d'images.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
async function setup(page, type, data) {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "basic.json", mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify({
|
||||||
|
toolbox: { id: "basic", name: "Basic workflows", modules: [{ id: "tool", type, title: "Tool" }] },
|
||||||
|
modules: { tool: data },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Basic workflows", exact: true }).first().click();
|
||||||
|
const tool = page.locator(`[data-module-type="${type}"]`);
|
||||||
|
await tool.getByRole("button", { name: "Afficher l'ajout de Tool", exact: true }).click();
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("links persist creation and copying; deleting a category preserves its links", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
const tool = await setup(page, "links", { links: [] });
|
||||||
|
const form = tool.locator(".links-add-form");
|
||||||
|
await form.getByPlaceholder("Nom du lien", { exact: true }).fill("Guide");
|
||||||
|
await form.locator('input[type="url"]').fill("https://example.com/guide");
|
||||||
|
await form.getByPlaceholder("Catégorie", { exact: true }).fill("Reference");
|
||||||
|
await form.getByRole("button", { name: "Ajouter", exact: true }).click();
|
||||||
|
const link = tool.getByRole("link", { name: /Guide/ });
|
||||||
|
await expect(link).toHaveAttribute("href", "https://example.com/guide");
|
||||||
|
await expect(link).toHaveAttribute("target", "_blank");
|
||||||
|
await expect(link).toHaveAttribute("rel", /noreferrer/);
|
||||||
|
await expect(form.locator('input[type="url"]')).toHaveValue("");
|
||||||
|
await tool.getByRole("button", { name: "Copier Guide", exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("https://example.com/guide");
|
||||||
|
await page.reload();
|
||||||
|
await expect(link).toHaveAttribute("href", "https://example.com/guide");
|
||||||
|
await tool.getByRole("button", { name: "Supprimer la catégorie Reference", exact: true }).click();
|
||||||
|
await expect(tool.getByRole("heading", { name: "Reference", exact: true })).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(link).toBeVisible();
|
||||||
|
await tool.getByRole("button", { name: "Supprimer Guide", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(link).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counters persist signed values and reset without affecting other counters", async ({ page }) => {
|
||||||
|
const tool = await setup(page, "counters", { counters: [{ id: "other", label: "Other", value: 9 }] });
|
||||||
|
await tool.getByPlaceholder("Nom du compteur", { exact: true }).fill("Attempts");
|
||||||
|
await tool.locator(".counters-add-form").getByRole("button", { name: "Ajouter", exact: true }).click();
|
||||||
|
const item = tool.locator(".counter-item").filter({ has: page.getByRole("button", { name: "Supprimer Attempts", exact: true }) });
|
||||||
|
await tool.getByRole("button", { name: "Incrémenter Attempts", exact: true }).click();
|
||||||
|
await expect(item.locator("strong")).toHaveText("1");
|
||||||
|
await tool.getByRole("button", { name: "Décrémenter Attempts", exact: true }).click({ clickCount: 2 });
|
||||||
|
await expect(item.locator("strong")).toHaveText("-1");
|
||||||
|
await page.reload();
|
||||||
|
await expect(item.locator("strong")).toHaveText("-1");
|
||||||
|
await tool.getByRole("button", { name: "Réinitialiser Attempts", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(item.locator("strong")).toHaveText("0");
|
||||||
|
await tool.getByRole("button", { name: "Supprimer Attempts", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(tool.locator(".counter-item")).toHaveCount(1);
|
||||||
|
await expect(tool.locator(".counter-value strong")).toHaveText("9");
|
||||||
|
await expect(tool.locator(".counter-value span")).toHaveText("Other");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty collection tools explain that they have no content", async ({ page }) => {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "empty-tools.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify({
|
||||||
|
toolbox: {
|
||||||
|
id: "empty-tools",
|
||||||
|
name: "Empty tools",
|
||||||
|
modules: [
|
||||||
|
{ id: "checklist", type: "checklist", title: "Checklist" },
|
||||||
|
{ id: "images", type: "images", title: "Images" },
|
||||||
|
{ id: "links", type: "links", title: "Links" },
|
||||||
|
{ id: "counters", type: "counters", title: "Counters" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
modules: {}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Empty tools", exact: true }).first().click();
|
||||||
|
await expect(page.getByText("Aucun élément dans cette checklist.", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Aucune image.", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Aucun lien.", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Aucun compteur.", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checklist quantities are clamped and completed categories hide without losing data", async ({ page }) => {
|
||||||
|
const tool = await setup(page, "checklist", { sections: [] });
|
||||||
|
await tool.getByPlaceholder("Catégorie", { exact: true }).fill("Craft");
|
||||||
|
await tool.getByPlaceholder("Nouvel item", { exact: true }).fill("Ore");
|
||||||
|
await tool.getByRole("spinbutton", { name: "Quantité cible", exact: true }).fill("3");
|
||||||
|
await tool.getByRole("button", { name: "Ajouter", exact: true }).click();
|
||||||
|
const quantity = tool.getByRole("spinbutton", { name: "Quantité actuelle Ore", exact: true });
|
||||||
|
await tool.getByRole("button", { name: "Ajouter une quantité", exact: true }).click();
|
||||||
|
await expect(quantity).toHaveValue("1");
|
||||||
|
await quantity.fill("99");
|
||||||
|
await expect(quantity).toHaveValue("3");
|
||||||
|
await tool.getByRole("radio", { name: "Cachée quand terminée", exact: true }).click();
|
||||||
|
await expect(quantity).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(tool.getByRole("radio", { name: "Cachée quand terminée", exact: true })).toHaveAttribute("aria-checked", "true");
|
||||||
|
await tool.getByRole("radio", { name: "Visible quand terminée", exact: true }).click();
|
||||||
|
await expect(quantity).toHaveValue("3");
|
||||||
|
const hideCompleted = tool.getByRole("button", { name: "Masquer les éléments terminés", exact: true });
|
||||||
|
await hideCompleted.click();
|
||||||
|
await expect(hideCompleted).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(quantity).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(hideCompleted).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await hideCompleted.click();
|
||||||
|
await expect(quantity).toHaveValue("3");
|
||||||
|
await tool.getByRole("button", { name: "Retirer une quantité", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(quantity).toHaveValue("2");
|
||||||
|
await tool.getByRole("button", { name: "Supprimer Ore", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(quantity).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a simple checklist can hide and restore checked rows", async ({ page }) => {
|
||||||
|
const tool = await setup(page, "checklist", {
|
||||||
|
items: [
|
||||||
|
{ id: "done", label: "Done", qtyCurrent: 1 },
|
||||||
|
{ id: "pending", label: "Pending" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const hideCompleted = tool.getByRole("button", { name: "Masquer les éléments terminés", exact: true });
|
||||||
|
await hideCompleted.click();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Terminer Done", exact: true })).toHaveCount(0);
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Terminer Pending", exact: true })).toBeVisible();
|
||||||
|
await hideCompleted.click();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Terminer Done", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("images open a gallery and navigate the current collection without looping", async ({ page }) => {
|
||||||
|
const makeImage = (color) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${color}"/></svg>`)}`;
|
||||||
|
const tool = await setup(page, "images", {
|
||||||
|
images: [
|
||||||
|
{ id: "one", label: "First", dataUrl: makeImage("#8b5cf6") },
|
||||||
|
{ id: "two", label: "Second", dataUrl: makeImage("#22d3ee") },
|
||||||
|
{ id: "three", dataUrl: makeImage("#f6c453") }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Galerie d'images", exact: true });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
const thumbnails = dialog.locator(".image-viewer-gallery-item");
|
||||||
|
const gallery = dialog.locator(".image-viewer-gallery");
|
||||||
|
const ratioButton = dialog.locator(".image-viewer-gallery-ratio-button");
|
||||||
|
await expect(thumbnails).toHaveCount(3);
|
||||||
|
await expect(ratioButton).toHaveText("4:3");
|
||||||
|
await expect(ratioButton).toHaveAccessibleName(/Passer au format 1:1/);
|
||||||
|
const initialCardBox = await thumbnails.first().boundingBox();
|
||||||
|
expect(initialCardBox.width / initialCardBox.height).toBeCloseTo(4 / 3, 2);
|
||||||
|
await ratioButton.click();
|
||||||
|
await expect(ratioButton).toHaveText("1:1");
|
||||||
|
await expect(ratioButton).toHaveAccessibleName(/Passer au format 4:3/);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const box = await thumbnails.first().boundingBox();
|
||||||
|
return box.width / box.height;
|
||||||
|
}).toBeCloseTo(1, 2);
|
||||||
|
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-card-ratio"))).toBe("1:1");
|
||||||
|
const initialThumbnailWidth = (await thumbnails.first().boundingBox()).width;
|
||||||
|
await dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(initialThumbnailWidth + 40, 0);
|
||||||
|
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-thumbnail-width"))).toBe("300");
|
||||||
|
expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(300, 0);
|
||||||
|
await expect(ratioButton).toHaveText("1:1");
|
||||||
|
await ratioButton.click();
|
||||||
|
await expect(ratioButton).toHaveText("4:3");
|
||||||
|
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-card-ratio"))).toBe("4:3");
|
||||||
|
await dialog.getByRole("button", { name: "Réduire les aperçus", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(260, 0);
|
||||||
|
await dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true }).click();
|
||||||
|
await expect(page.locator(".image-viewer-title")).toHaveText("Second");
|
||||||
|
await expect(page.locator(".image-viewer-position")).toHaveText("2 / 3");
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await expect(page.locator(".image-viewer-title")).toHaveText("Image");
|
||||||
|
await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled();
|
||||||
|
await page.keyboard.press("ArrowLeft");
|
||||||
|
await expect(page.locator(".image-viewer-title")).toHaveText("Second");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Revenir à la galerie", exact: true }).click();
|
||||||
|
const secondThumbnail = dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true });
|
||||||
|
await expect(secondThumbnail).toBeFocused();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
|
||||||
|
const galleryBox = await dialog.boundingBox();
|
||||||
|
expect(galleryBox.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(galleryBox.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(galleryBox.x + galleryBox.width).toBeLessThanOrEqual(390);
|
||||||
|
expect(galleryBox.y + galleryBox.height).toBeLessThanOrEqual(844);
|
||||||
|
await ratioButton.click();
|
||||||
|
await expect(ratioButton).toHaveText("1:1");
|
||||||
|
const zoomIn = dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true });
|
||||||
|
for (let step = 0; step < 4; step += 1) await zoomIn.click();
|
||||||
|
await expect(zoomIn).toBeDisabled();
|
||||||
|
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-thumbnail-width"))).toBe("420");
|
||||||
|
expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
|
||||||
|
await tool.locator(".image-preview").nth(2).click();
|
||||||
|
await expect(page.locator(".image-viewer-position")).toHaveText("3 / 3");
|
||||||
|
await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled();
|
||||||
|
await page.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("image gallery keeps proportional cards and compact centered rows at maximum zoom", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1550, height: 900 });
|
||||||
|
const makeImage = (index) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="hsl(${index * 45} 70% 45%)"/></svg>`)}`;
|
||||||
|
const tool = await setup(page, "images", {
|
||||||
|
images: Array.from({ length: 7 }, (_, index) => ({ id: `image-${index}`, label: `Image ${index + 1}`, dataUrl: makeImage(index) }))
|
||||||
|
});
|
||||||
|
await page.evaluate(() => globalThis.localStorage.setItem("sokkog:image-gallery-thumbnail-width", "420"));
|
||||||
|
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Galerie d'images", exact: true });
|
||||||
|
const gallery = dialog.locator(".image-viewer-gallery");
|
||||||
|
const cards = gallery.locator(".image-viewer-gallery-item");
|
||||||
|
await expect(cards).toHaveCount(7);
|
||||||
|
await expect.poll(() => cards.first().locator("img").evaluate((image) => image.naturalWidth)).toBe(640);
|
||||||
|
const boxes = await cards.evaluateAll((elements) => elements.map((element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||||
|
}));
|
||||||
|
expect(boxes[0].width / boxes[0].height).toBeCloseTo(4 / 3, 2);
|
||||||
|
expect(boxes.slice(0, 3).every((box) => Math.abs(box.y - boxes[0].y) < 1)).toBe(true);
|
||||||
|
expect(boxes.slice(3, 6).every((box) => Math.abs(box.y - boxes[3].y) < 1)).toBe(true);
|
||||||
|
expect(boxes[3].y).toBeGreaterThan(boxes[0].y + boxes[0].height);
|
||||||
|
expect(boxes[6].y).toBeGreaterThan(boxes[3].y + boxes[3].height);
|
||||||
|
|
||||||
|
const gap = boxes[1].x - boxes[0].x - boxes[0].width;
|
||||||
|
const galleryBox = await gallery.boundingBox();
|
||||||
|
expect(gap).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(gap).toBeLessThanOrEqual(14);
|
||||||
|
expect(boxes[0].x - galleryBox.x).toBeGreaterThan(gap * 2);
|
||||||
|
expect(galleryBox.x + galleryBox.width - boxes[2].x - boxes[2].width).toBeGreaterThan(gap * 2);
|
||||||
|
expect(boxes[6].x + boxes[6].width / 2).toBeCloseTo(galleryBox.x + galleryBox.width / 2, 0);
|
||||||
|
|
||||||
|
const firstCard = cards.first();
|
||||||
|
const frameBox = await firstCard.locator(".image-viewer-gallery-frame").boundingBox();
|
||||||
|
const imageBox = await firstCard.locator("img").boundingBox();
|
||||||
|
const captionBox = await firstCard.locator(".image-viewer-gallery-caption").boundingBox();
|
||||||
|
expect(imageBox.x).toBeGreaterThanOrEqual(frameBox.x);
|
||||||
|
expect(imageBox.y).toBeGreaterThanOrEqual(frameBox.y);
|
||||||
|
expect(imageBox.x + imageBox.width).toBeLessThanOrEqual(frameBox.x + frameBox.width);
|
||||||
|
expect(imageBox.y + imageBox.height).toBeLessThanOrEqual(frameBox.y + frameBox.height);
|
||||||
|
expect(captionBox.y + captionBox.height).toBeLessThanOrEqual(boxes[0].y + boxes[0].height);
|
||||||
|
expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
expect(await gallery.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||||
|
});
|
||||||
159
e2e/calculator.spec.js
Normal file
159
e2e/calculator.spec.js
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// Rôle : vérifie les calculs hiérarchiques et les actions du calculateur dans une toolbox réelle.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByRole("button", { name: "Nouvelle toolbox" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Nouvelle toolbox" });
|
||||||
|
await dialog.getByLabel("Nom").fill("Calculator workflows");
|
||||||
|
await dialog.getByRole("button", { name: "Créer", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Ajouter un outil", exact: true }).click();
|
||||||
|
await page.getByRole("dialog", { name: "Ajouter un outil" }).getByRole("button", { name: "Calculateur" }).click();
|
||||||
|
return page.locator('[data-module-type="calculator"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(tool, label) {
|
||||||
|
return tool.locator(".calculator-entry").filter({ has: tool.page().getByRole("button", { name: `Supprimer ${label}`, exact: true }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRoot(tool, formula, label) {
|
||||||
|
await tool.getByRole("textbox", { name: "Calcul", exact: true }).fill(formula);
|
||||||
|
await tool.getByLabel("Libellé", { exact: true }).fill(label);
|
||||||
|
await tool.getByRole("button", { name: "Enregistrer", exact: true }).click();
|
||||||
|
await expect(entry(tool, label)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parent edits recalculate descendants and survive reload; deleting a parent removes its subtree", async ({ page }) => {
|
||||||
|
const tool = await setup(page);
|
||||||
|
const expression = tool.getByRole("textbox", { name: "Calcul", exact: true });
|
||||||
|
await expression.fill("2 +");
|
||||||
|
await expect(tool.getByRole("button", { name: "Enregistrer", exact: true })).toBeDisabled();
|
||||||
|
await saveRoot(tool, "2 + 3 * 4", "Parent");
|
||||||
|
await expect(entry(tool, "Parent").locator("strong")).toHaveAttribute("aria-label", "14");
|
||||||
|
await tool.getByRole("button", { name: "Ajouter un calcul enfant Parent", exact: true }).click();
|
||||||
|
await expect(expression).toBeFocused();
|
||||||
|
await expect(expression).toContainText("Valeur du parent");
|
||||||
|
await expression.press("End");
|
||||||
|
await expression.pressSequentially(" * 3");
|
||||||
|
await tool.getByLabel("Libellé", { exact: true }).fill("Child");
|
||||||
|
await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click();
|
||||||
|
await expect(entry(tool, "Child").locator("strong")).toHaveAttribute("aria-label", "42");
|
||||||
|
await entry(tool, "Parent").getByTitle("Modifier", { exact: true }).click();
|
||||||
|
await expression.fill("20");
|
||||||
|
await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click();
|
||||||
|
await expect(entry(tool, "Child").locator("strong")).toHaveAttribute("aria-label", "60");
|
||||||
|
await page.reload();
|
||||||
|
await expect(entry(tool, "Parent").locator("strong")).toHaveAttribute("aria-label", "20");
|
||||||
|
await expect(entry(tool, "Child").locator("strong")).toHaveAttribute("aria-label", "60");
|
||||||
|
await entry(tool, "Child").getByTitle("Modifier", { exact: true }).click();
|
||||||
|
await tool.getByRole("button", { name: "Supprimer Parent", exact: true }).click();
|
||||||
|
await expect(tool.locator(".calculator-entry")).toHaveCount(0);
|
||||||
|
await expect(expression).toHaveValue("");
|
||||||
|
await expect(tool.getByRole("button", { name: "Enregistrer", exact: true })).toBeDisabled();
|
||||||
|
await page.reload();
|
||||||
|
await expect(tool.getByText("Aucun résultat enregistré.", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("current values are subtracted from totals and kept during recalculation", async ({ page }) => {
|
||||||
|
const tool = await setup(page);
|
||||||
|
const expression = tool.getByRole("textbox", { name: "Calcul", exact: true });
|
||||||
|
const current = tool.getByLabel("Actuel", { exact: true });
|
||||||
|
const totalLabel = tool.getByText("Total", { exact: true });
|
||||||
|
await expect(current).toHaveCount(0);
|
||||||
|
await expect(totalLabel).toHaveCount(0);
|
||||||
|
const subtractCurrent = tool.getByRole("button", { name: "Soustraire l'actuel", exact: true });
|
||||||
|
await expect(subtractCurrent).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await subtractCurrent.click();
|
||||||
|
await expect(subtractCurrent).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(current).toBeFocused();
|
||||||
|
await expect(totalLabel).toBeVisible();
|
||||||
|
await expect(tool.locator(".calculator-result-field")).toHaveClass(/has-current/);
|
||||||
|
await expect(tool.locator(".calculator-result").getByText("Résultat", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(tool.locator(".calculator-result-field > span")).toHaveText("Résultat");
|
||||||
|
const expressionBounds = await expression.boundingBox();
|
||||||
|
const currentBounds = await current.boundingBox();
|
||||||
|
expect(currentBounds.x).toBeCloseTo(expressionBounds.x, 0);
|
||||||
|
expect(currentBounds.width).toBeCloseTo(expressionBounds.width, 0);
|
||||||
|
await expression.fill("10");
|
||||||
|
await current.fill("2");
|
||||||
|
await tool.getByLabel("Libellé", { exact: true }).fill("Parent");
|
||||||
|
await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "8");
|
||||||
|
await tool.getByRole("button", { name: "Enregistrer", exact: true }).click();
|
||||||
|
await tool.getByRole("button", { name: "Ajouter un calcul enfant Parent", exact: true }).click();
|
||||||
|
await expression.fill("base * 10");
|
||||||
|
await subtractCurrent.click();
|
||||||
|
await current.fill("20");
|
||||||
|
await tool.getByLabel("Libellé", { exact: true }).fill("Child");
|
||||||
|
await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "60");
|
||||||
|
await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click();
|
||||||
|
await entry(tool, "Parent").getByTitle("Modifier", { exact: true }).click();
|
||||||
|
await expect(current).toHaveValue("2");
|
||||||
|
await expression.fill("20");
|
||||||
|
await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click();
|
||||||
|
await expect(entry(tool, "Child").locator("strong")).toHaveAttribute("aria-label", "160");
|
||||||
|
await page.reload();
|
||||||
|
await entry(tool, "Child").getByTitle("Modifier", { exact: true }).click();
|
||||||
|
await expect(current).toHaveValue("20");
|
||||||
|
await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "160");
|
||||||
|
await subtractCurrent.click();
|
||||||
|
await expect(current).toHaveCount(0);
|
||||||
|
await expect(tool.locator(".calculator-result-field")).not.toHaveClass(/has-current/);
|
||||||
|
await expect(tool.locator(".calculator-result strong")).toHaveAttribute("aria-label", "180");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reset clears only the draft, new calculation returns to root, and clipboard and scroll preferences persist", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
const tool = await setup(page);
|
||||||
|
await expect(tool.getByRole("button", { name: "Copier pour checklist", exact: true })).toBeDisabled();
|
||||||
|
await saveRoot(tool, "10", "Ore");
|
||||||
|
await tool.getByRole("button", { name: "Ajouter un calcul enfant Ore", exact: true }).click();
|
||||||
|
await tool.getByRole("textbox", { name: "Calcul", exact: true }).fill("base * 2");
|
||||||
|
await tool.getByLabel("Libellé", { exact: true }).fill("Bars");
|
||||||
|
await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click();
|
||||||
|
await tool.getByRole("button", { name: "Nouveau calcul", exact: true }).click();
|
||||||
|
await saveRoot(tool, "7", "Spare");
|
||||||
|
await tool.getByRole("textbox", { name: "Calcul", exact: true }).fill("99");
|
||||||
|
await tool.getByLabel("Libellé", { exact: true }).fill("Unsaved");
|
||||||
|
await tool.getByRole("button", { name: "Réinitialiser", exact: true }).click();
|
||||||
|
await expect(tool.getByRole("textbox", { name: "Calcul", exact: true })).toHaveValue("");
|
||||||
|
await expect(tool.getByLabel("Libellé", { exact: true })).toHaveValue("");
|
||||||
|
await expect(tool.locator(".calculator-entry")).toHaveCount(3);
|
||||||
|
await tool.getByRole("button", { name: "Copier pour checklist", exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Ore:10\nBars:20\nSpare:7");
|
||||||
|
const scroll = tool.getByRole("button", { name: "Liste scrollable", exact: true });
|
||||||
|
await expect(scroll).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await scroll.click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(scroll).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(tool.locator(".calculator-tree")).toHaveCSS("overflow-y", "auto");
|
||||||
|
await scroll.click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(scroll).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(tool.locator(".calculator-entry")).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reordering roots keeps descendants attached and rejects moving a child to another parent", async ({ page }) => {
|
||||||
|
const tool = await setup(page);
|
||||||
|
await saveRoot(tool, "10", "First");
|
||||||
|
await saveRoot(tool, "20", "Second");
|
||||||
|
await tool.getByRole("button", { name: "Ajouter un calcul enfant First", exact: true }).click();
|
||||||
|
await tool.getByLabel("Libellé", { exact: true }).fill("Child");
|
||||||
|
await tool.getByRole("button", { name: "Mettre à jour", exact: true }).click();
|
||||||
|
async function drag(label, targetLabel) {
|
||||||
|
await tool.getByRole("button", { name: `Déplacer ${label}`, exact: true }).hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
const target = entry(tool, targetLabel);
|
||||||
|
await target.scrollIntoViewIfNeeded();
|
||||||
|
const box = await target.boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height * 0.2, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
await drag("Second", "First");
|
||||||
|
await expect(tool.locator(".calculator-entry em")).toHaveText(["Second", "First", "Child"]);
|
||||||
|
await drag("Child", "Second");
|
||||||
|
await expect(tool.locator(".calculator-entry em")).toHaveText(["Second", "First", "Child"]);
|
||||||
|
await page.reload();
|
||||||
|
await expect(tool.locator(".calculator-entry em")).toHaveText(["Second", "First", "Child"]);
|
||||||
|
await tool.getByRole("button", { name: "Supprimer First", exact: true }).click();
|
||||||
|
await expect(tool.locator(".calculator-entry em")).toHaveText(["Second"]);
|
||||||
|
});
|
||||||
106
e2e/calendar-layout.spec.js
Normal file
106
e2e/calendar-layout.spec.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// Rôle : vérifie la lisibilité et la zone cliquable des événements calendrier denses.
|
||||||
|
/* global getComputedStyle */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
async function seedCalendars(page) {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
const dateKey = (day) => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
await db.setToolboxSnapshot([{
|
||||||
|
id: "calendar-layout",
|
||||||
|
name: "Calendar layout",
|
||||||
|
moduleColumns: 1,
|
||||||
|
modules: [
|
||||||
|
{ id: "week", type: "calendar", title: "Week" },
|
||||||
|
{ id: "month", type: "calendar", title: "Month" }
|
||||||
|
]
|
||||||
|
}], {}, {
|
||||||
|
"calendar-layout:week": {
|
||||||
|
initialized: true,
|
||||||
|
viewMode: "week",
|
||||||
|
events: [30, 60, 120].map((duration, index) => ({
|
||||||
|
id: `week-${index}`, title: `Raid ${duration}`, dayIndex: index,
|
||||||
|
startMinutes: 600, endMinutes: 600 + duration, color: "violet"
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
"calendar-layout:month": {
|
||||||
|
initialized: true,
|
||||||
|
viewMode: "month",
|
||||||
|
events: [0, 1, 2, 3].map((index) => ({
|
||||||
|
id: `month-${index}`, title: `Event ${index}`, repeat: "none",
|
||||||
|
startDate: dateKey(index < 3 ? 10 : 20), endDate: dateKey(index < 3 ? 10 : 20),
|
||||||
|
color: "gold"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/#/toolbox/calendar-layout");
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectTitleFits(surface) {
|
||||||
|
await surface.scrollIntoViewIfNeeded();
|
||||||
|
const box = await surface.evaluate((element) => {
|
||||||
|
const button = element.querySelector("button");
|
||||||
|
const title = button.querySelector("strong");
|
||||||
|
const outer = element.getBoundingClientRect();
|
||||||
|
const inner = button.getBoundingClientRect();
|
||||||
|
const text = title.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
height: outer.height,
|
||||||
|
buttonHeight: inner.height,
|
||||||
|
titleHeight: text.height,
|
||||||
|
topSpace: text.top - outer.top - parseFloat(style.borderTopWidth),
|
||||||
|
bottomSpace: outer.bottom - parseFloat(style.borderBottomWidth) - text.bottom,
|
||||||
|
buttonTopSpace: text.top - inner.top,
|
||||||
|
buttonBottomSpace: inner.bottom - text.bottom
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(box.titleHeight).toBeGreaterThan(0);
|
||||||
|
expect(box.buttonHeight).toBeGreaterThanOrEqual(box.height - 2);
|
||||||
|
for (const gap of [box.topSpace, box.bottomSpace, box.buttonTopSpace, box.buttonBottomSpace]) {
|
||||||
|
expect(gap, JSON.stringify(box)).toBeGreaterThanOrEqual(-0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
test(`dense calendar titles fit and remain editable at ${width}px`, async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.clock.setFixedTime(new Date("2026-09-01T12:00:00Z"));
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await seedCalendars(page);
|
||||||
|
const week = page.locator('[data-module-type="calendar"]').first();
|
||||||
|
const month = page.locator('[data-module-type="calendar"]').last();
|
||||||
|
await expect(week.locator(".calendar-event")).toHaveCount(3);
|
||||||
|
await expect(month.locator(".calendar-month-bar")).toHaveCount(4);
|
||||||
|
for (const size of ["xs", "sm", "md"]) {
|
||||||
|
const surface = week.locator(`.calendar-event[data-size="${size}"]`);
|
||||||
|
await expect(surface).toHaveCount(1);
|
||||||
|
await expectTitleFits(surface);
|
||||||
|
}
|
||||||
|
for (const size of ["xs", "sm"]) {
|
||||||
|
const surfaces = month.locator(`.calendar-month-bar[data-size="${size}"]`);
|
||||||
|
await expect(surfaces).toHaveCount(size === "xs" ? 3 : 1);
|
||||||
|
for (const surface of await surfaces.all()) await expectTitleFits(surface);
|
||||||
|
}
|
||||||
|
await month.screenshot({ path: testInfo.outputPath("month.png") });
|
||||||
|
await week.screenshot({ path: testInfo.outputPath("week.png") });
|
||||||
|
for (const [module, selector, title] of [
|
||||||
|
[week, ".calendar-event", "Raid 30"],
|
||||||
|
[month, ".calendar-month-bar", "Event 0"]
|
||||||
|
]) {
|
||||||
|
const button = module.locator(`${selector}[data-size="xs"] button`).first();
|
||||||
|
await button.click();
|
||||||
|
await expect(module.getByLabel("Titre", { exact: true })).toHaveValue(title);
|
||||||
|
await module.getByRole("button", { name: "Annuler", exact: true }).click();
|
||||||
|
}
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
91
e2e/card-surfaces.spec.js
Normal file
91
e2e/card-surfaces.spec.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Rôle : vérifie les surfaces de cartes à partir du Sass compilé et des styles calculés.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { mountStyles } from "./fixtures/compiled-styles.js";
|
||||||
|
|
||||||
|
async function mountCards(page) {
|
||||||
|
await mountStyles(page, `
|
||||||
|
<main style="display:grid;gap:24px;padding:24px">
|
||||||
|
<article id="global" class="nds-card" data-variant="interactive">
|
||||||
|
<div class="nds-card-cover">Cover</div><button>Open</button>
|
||||||
|
</article>
|
||||||
|
<a id="home" class="nds-card game-home-card" href="#game">
|
||||||
|
<span class="game-home-card-icon">Icon</span><strong>Game</strong>
|
||||||
|
</a>
|
||||||
|
<article id="endemic" class="mhwilds-card endemic-card">
|
||||||
|
<div class="mhwilds-card-art">Art</div><div class="mhwilds-card-body">Endemic</div>
|
||||||
|
</article>
|
||||||
|
<article id="monster" class="mhwilds-card monster-card" tabindex="0">
|
||||||
|
<span class="mhwilds-flip-indicator">Flip</span>
|
||||||
|
<div class="mhwilds-card-inner">
|
||||||
|
<div class="mhwilds-card-face mhwilds-card-front"><div class="mhwilds-card-art">Front</div></div>
|
||||||
|
<div class="mhwilds-card-face mhwilds-card-back"><div class="mhwilds-card-body">Back</div></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSurface(card, { hovered = false, face = false } = {}) {
|
||||||
|
await expect(card).toHaveCSS("border-top-width", "1px");
|
||||||
|
await expect(card).toHaveCSS("border-top-style", "solid");
|
||||||
|
await expect(card).toHaveCSS("border-top-color", "rgba(0, 0, 0, 0)");
|
||||||
|
await expect(card).toHaveCSS("background-clip", hovered ? "padding-box, border-box" : "padding-box, border-box, border-box");
|
||||||
|
const fill = hovered ? (face ? "0.95" : "0.96") : (face ? "0.92" : "0.88");
|
||||||
|
await expect(card).toHaveCSS("background-image", new RegExp(`rgba\\(${hovered && !face ? "21, 26, 48" : "16, 20, 38"}, ${fill.replace(".", "\\.")}\\)`));
|
||||||
|
await expect(card).toHaveCSS("background-image", hovered ? /139, 92, 246/ : /246, 196, 83/);
|
||||||
|
await expect(card).toHaveCSS("background-image", hovered ? /196, 181, 253/ : /34, 211, 238/);
|
||||||
|
if (hovered) await expect(card).toHaveCSS("box-shadow", /139, 92, 246/);
|
||||||
|
const radius = await card.evaluate((element) => element.ownerDocument.defaultView.getComputedStyle(element).getPropertyValue("--radius-md").trim());
|
||||||
|
expect(radius).not.toBe("");
|
||||||
|
await expect(card).toHaveCSS("border-top-left-radius", radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [1280, 390]) {
|
||||||
|
test(`compiled card surfaces preserve borders, dividers and hover at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await mountCards(page);
|
||||||
|
for (const id of ["global", "home", "endemic"]) {
|
||||||
|
const card = page.locator(`#${id}`);
|
||||||
|
await expectSurface(card);
|
||||||
|
const before = await card.boundingBox();
|
||||||
|
await card.hover();
|
||||||
|
await expectSurface(card, { hovered: true });
|
||||||
|
await expect(card).toHaveCSS("transform", "none");
|
||||||
|
const after = await card.boundingBox();
|
||||||
|
expect(after.width).toBe(before.width);
|
||||||
|
expect(after.height).toBe(before.height);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await expectSurface(card);
|
||||||
|
}
|
||||||
|
for (const selector of [".nds-card-cover", ".game-home-card-icon", ".mhwilds-card-art"]) {
|
||||||
|
for (const divider of await page.locator(selector).all()) {
|
||||||
|
await expect(divider).toHaveCSS("border-bottom", "1px solid rgba(246, 196, 83, 0.42)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(await page.locator("body").evaluate((element) => element.scrollWidth)).toBeLessThanOrEqual(width);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`compiled monster faces and keyboard card state remain distinct at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await mountCards(page);
|
||||||
|
const global = page.locator("#global");
|
||||||
|
await global.getByRole("button").focus();
|
||||||
|
await expectSurface(global, { hovered: true });
|
||||||
|
const monster = page.locator("#monster");
|
||||||
|
await expect(monster.locator(".mhwilds-flip-indicator")).toHaveCSS("pointer-events", "none");
|
||||||
|
await expect(monster.locator(".mhwilds-flip-indicator")).toHaveCSS("width", "34px");
|
||||||
|
for (const face of await monster.locator(".mhwilds-card-face").all()) {
|
||||||
|
await expectSurface(face, { face: true });
|
||||||
|
await expect(face).toHaveCSS("backface-visibility", "hidden");
|
||||||
|
}
|
||||||
|
await monster.hover();
|
||||||
|
await expect(monster).toHaveCSS("background-image", "none");
|
||||||
|
await expect(monster).toHaveCSS("box-shadow", "none");
|
||||||
|
for (const face of await monster.locator(".mhwilds-card-face").all()) {
|
||||||
|
await expectSurface(face, { face: true, hovered: true });
|
||||||
|
await expect(face).toHaveCSS("box-shadow", /14px/);
|
||||||
|
}
|
||||||
|
await monster.evaluate((element) => element.classList.add("is-flipped"));
|
||||||
|
await expect(monster.locator(".mhwilds-card-inner")).toHaveCSS("transform", "matrix3d(-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1)");
|
||||||
|
});
|
||||||
|
}
|
||||||
187
e2e/deferred-media.spec.js
Normal file
187
e2e/deferred-media.spec.js
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
// Rôle : vérifie les lectures à la demande, les exports complets et la conservation des médias non chargés.
|
||||||
|
/* global window, document, IDBObjectStore, DOMException */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.route("**/__media-seed", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><title>Seed</title>" }));
|
||||||
|
await page.goto("/__media-seed");
|
||||||
|
const seeded = await page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvas.height = 32;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = "red";
|
||||||
|
context.fillRect(0, 0, 32, 32);
|
||||||
|
const red = canvas.toDataURL();
|
||||||
|
context.fillStyle = "green";
|
||||||
|
context.fillRect(0, 0, 32, 32);
|
||||||
|
const green = canvas.toDataURL();
|
||||||
|
const targetAt = Date.now() + 60000;
|
||||||
|
const currentDate = new Date();
|
||||||
|
const currentDateKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`;
|
||||||
|
const toolboxes = [
|
||||||
|
{ id: "a", name: "Alpha", updatedAt: "2026-09-12T12:00:00Z", modules: [
|
||||||
|
{ id: "images", type: "images", title: "Images" },
|
||||||
|
{ id: "map", type: "imageAnnotation", title: "Carte" },
|
||||||
|
{ id: "timer", type: "timer", title: "Timer" },
|
||||||
|
{ id: "calendar", type: "calendar", title: "Calendrier" }
|
||||||
|
] },
|
||||||
|
{ id: "b", name: "Beta", updatedAt: "2026-09-12T12:00:00Z", modules: [{ id: "images", type: "images", title: "Images" }] }
|
||||||
|
];
|
||||||
|
const modules = {
|
||||||
|
"a:images": { images: [{ id: "red", label: "Rouge", dataUrl: red }] },
|
||||||
|
"a:map": { image: green, markers: [{ id: "marker", x: 25, y: 50, label: "Entrée" }], drawings: { strokes: [{ id: "stroke", color: "#f6c453", width: 4, points: [{ x: 10, y: 10 }, { x: 80, y: 80 }] }] } },
|
||||||
|
"a:timer": { countdowns: [{ id: "alert", type: "duration", label: "Alerte fermée", targetAt, durationMs: 60000, alertMode: "site" }] },
|
||||||
|
"a:calendar": { viewMode: "month", events: [{ id: "current", title: "Événement persistant", startDate: currentDateKey, endDate: currentDateKey, repeat: "none", alertMode: "site", alertOffsetMinutes: [0] }] },
|
||||||
|
"b:images": { images: [{ id: "green", label: "Vert", dataUrl: green }] }
|
||||||
|
};
|
||||||
|
await db.setToolboxSnapshot(toolboxes, { mhwilds: "a" }, modules);
|
||||||
|
return { toolboxes, modules, targetAt };
|
||||||
|
});
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.moduleReads = [];
|
||||||
|
window.failedMediaKey = "";
|
||||||
|
const get = IDBObjectStore.prototype.get;
|
||||||
|
IDBObjectStore.prototype.get = function (key) {
|
||||||
|
if (this.name === "modules") {
|
||||||
|
window.moduleReads.push(key);
|
||||||
|
if (key === window.failedMediaKey) throw new DOMException("Média indisponible", "UnknownError");
|
||||||
|
}
|
||||||
|
return get.call(this, key);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(page.locator(".toolbox-card")).toHaveCount(2);
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disk(page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
return { toolboxes: await db.getToolboxes(), modules: Object.fromEntries((await db.getAllModuleData()).map((entry) => [entry.key, entry.data])) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportJson(page, buttonName = "Exporter tout") {
|
||||||
|
const download = page.waitForEvent("download");
|
||||||
|
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
||||||
|
const stream = await (await download).createReadStream();
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of stream) chunks.push(chunk);
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
test("closed media stays unread while site-wide timer alerts still fire", async ({ page }) => {
|
||||||
|
const seeded = await setup(page);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads)).not.toEqual([]);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.every((key) => ["a:timer", "a:calendar"].includes(key)))).toBe(true);
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "Événement persistant" })).toBeVisible();
|
||||||
|
await page.clock.install({ time: new Date(seeded.targetAt - 3000) });
|
||||||
|
await page.clock.pauseAt(new Date(seeded.targetAt - 2000));
|
||||||
|
await page.clock.runFor(3000);
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "Alerte fermée" })).toBeVisible();
|
||||||
|
await page.clock.runFor(7000);
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "Alerte fermée" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "Événement persistant" })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.every((key) => ["a:timer", "a:calendar"].includes(key)))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creation, import and deletion preserve unread media and global exports include it", async ({ page }) => {
|
||||||
|
const seeded = await setup(page);
|
||||||
|
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("New");
|
||||||
|
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await disk(page)).toolboxes.length).toBe(3);
|
||||||
|
expect((await disk(page)).modules).toMatchObject(seeded.modules);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.every((key) => ["a:timer", "a:calendar"].includes(key)))).toBe(true);
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByText("Importer une toolbox", { exact: true }).locator("input").setInputFiles({
|
||||||
|
name: "import.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify({ toolbox: { id: "source", name: "Imported", modules: [] }, modules: {} }))
|
||||||
|
});
|
||||||
|
await expect(page.locator(".toolbox-card")).toHaveCount(4);
|
||||||
|
expect((await disk(page)).modules).toMatchObject(seeded.modules);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.every((key) => ["a:timer", "a:calendar"].includes(key)))).toBe(true);
|
||||||
|
const exported = await exportJson(page);
|
||||||
|
for (const key of ["a:images", "a:map", "b:images"]) expect(exported.modules[key]).toEqual(seeded.modules[key]);
|
||||||
|
await page.getByRole("button", { name: "Supprimer Beta", exact: true }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Supprimer", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await disk(page)).modules["b:images"]).toBeUndefined();
|
||||||
|
expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]);
|
||||||
|
expect((await disk(page)).modules["a:map"]).toEqual(seeded.modules["a:map"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single exports include deferred media without reading other toolboxes or retaining it", async ({ page }) => {
|
||||||
|
const seeded = await setup(page);
|
||||||
|
const exported = await exportJson(page, "Exporter Alpha");
|
||||||
|
const imageModule = exported.toolbox.modules.find((module) => module.type === "images");
|
||||||
|
const mapModule = exported.toolbox.modules.find((module) => module.type === "imageAnnotation");
|
||||||
|
expect(exported.modules[imageModule.id].images[0].dataUrl).toBe(seeded.modules["a:images"].images[0].dataUrl);
|
||||||
|
expect(exported.modules[mapModule.id].image).toBe(seeded.modules["a:map"].image);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
|
||||||
|
const reads = await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length);
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||||
|
await expect(page.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length)).toBe(reads + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening and editing one toolbox only reads its media and reuses it on return", async ({ page }) => {
|
||||||
|
const seeded = await setup(page);
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||||
|
const image = page.locator('[data-module-type="images"] .image-preview img');
|
||||||
|
await expect(image).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||||
|
await expect.poll(() => image.evaluate((element) => element.naturalWidth)).toBe(32);
|
||||||
|
await expect(page.locator(".annotation-marker")).toHaveCount(1);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
|
||||||
|
const initialReads = await page.evaluate(() => window.moduleReads.length);
|
||||||
|
await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Libellé de l'image", exact: true }).fill("Edited");
|
||||||
|
await expect.poll(async () => (await disk(page)).modules["a:images"].images[0].label).toBe("Edited");
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||||
|
await expect(page.getByText("Edited", { exact: true })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.length)).toBe(initialReads);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the game drawer loads only the linked toolbox media", async ({ page }) => {
|
||||||
|
const seeded = await setup(page);
|
||||||
|
await page.goto("/#/games/mhwilds");
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
const drawer = page.locator("#toolbox-drawer .drawer-panel");
|
||||||
|
await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||||
|
await expect(drawer.locator(".annotation-marker")).toHaveCount(1);
|
||||||
|
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("media read errors block only the affected tool and retry without overwriting it", async ({ page }) => {
|
||||||
|
const seeded = await setup(page);
|
||||||
|
await page.evaluate(() => { window.failedMediaKey = "a:images"; });
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||||
|
const module = page.locator('[data-module-type="images"]');
|
||||||
|
await expect(module.getByRole("alert")).toHaveText("Média indisponible");
|
||||||
|
await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click();
|
||||||
|
await expect(module.locator('input[type="file"], .image-preview')).toHaveCount(0);
|
||||||
|
await expect(page.locator(".annotation-marker")).toHaveCount(1);
|
||||||
|
expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]);
|
||||||
|
await module.getByRole("button", { name: "Réessayer" }).click();
|
||||||
|
await expect(module.getByRole("alert")).toHaveText("Média indisponible");
|
||||||
|
await page.evaluate(() => { window.failedMediaKey = ""; });
|
||||||
|
await module.getByRole("button", { name: "Réessayer" }).click();
|
||||||
|
await expect(module.locator(".image-preview img")).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed media exports produce no partial download and can be retried", async ({ page }) => {
|
||||||
|
const seeded = await setup(page);
|
||||||
|
const downloads = [];
|
||||||
|
page.on("download", (download) => downloads.push(download));
|
||||||
|
await page.evaluate(() => { window.failedMediaKey = "b:images"; });
|
||||||
|
await page.getByRole("button", { name: "Exporter tout", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Erreur de stockage local" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect(downloads).toHaveLength(0);
|
||||||
|
await dialog.getByRole("button", { name: "Compris" }).click();
|
||||||
|
await page.evaluate(() => { window.failedMediaKey = ""; });
|
||||||
|
const exported = await exportJson(page);
|
||||||
|
expect(exported.modules["b:images"]).toEqual(seeded.modules["b:images"]);
|
||||||
|
expect(downloads).toHaveLength(1);
|
||||||
|
});
|
||||||
166
e2e/editor-lifecycle.spec.js
Normal file
166
e2e/editor-lifecycle.spec.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// Rôle : vérifie les brouillons RNG et les changements d'image sans état périmé sous StrictMode.
|
||||||
|
/* global window, document */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/editor-lifecycle-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
async function setup(page, kind, props = {}) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async ({ url, kind, props }) => {
|
||||||
|
const { mountEditorProbe, controlImageDecoding } = await import(url);
|
||||||
|
document.getElementById("app").style.display = "none";
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.id = "editor-probe";
|
||||||
|
document.body.prepend(element);
|
||||||
|
if (kind === "cover" || kind === "viewer") {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 800;
|
||||||
|
canvas.height = 400;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.fillStyle = "#22d3ee";
|
||||||
|
ctx.fillRect(0, 0, 800, 400);
|
||||||
|
window.probeImage = canvas.toDataURL();
|
||||||
|
if (kind === "cover") {
|
||||||
|
window.probeFile = await new Promise((resolve) => canvas.toBlob(resolve));
|
||||||
|
window.decoding = controlImageDecoding();
|
||||||
|
if (props.existingCover) props.existingCover = window.probeImage;
|
||||||
|
else props.file = window.probeFile;
|
||||||
|
} else {
|
||||||
|
window.viewerImage = {
|
||||||
|
dataUrl: window.probeImage, canAnnotate: true,
|
||||||
|
markers: [{ id: "a", label: "First", x: 30, y: 30 }],
|
||||||
|
onChangeMarkers: () => {}, onChangeDrawings: () => {}
|
||||||
|
};
|
||||||
|
props.image = window.viewerImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.editorProbe = mountEditorProbe(element, kind, props);
|
||||||
|
}, { url: probeUrl, kind, props });
|
||||||
|
return page.locator("#editor-probe");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("RNG drafts follow rate and module identity without losing unrelated edits", async ({ page }) => {
|
||||||
|
const rates = [
|
||||||
|
{ id: "a", label: "First", rate: "10", costPerDraw: "12" },
|
||||||
|
{ id: "b", label: "Second", rate: "20", costPerDraw: "34", occurrenceValue: 2, occurrenceUnit: "perDay" }
|
||||||
|
];
|
||||||
|
const editor = await setup(page, "rng", { data: { rates, activeRateId: "a" } });
|
||||||
|
const cost = editor.getByLabel("Coût d'un tirage");
|
||||||
|
await cost.fill("999");
|
||||||
|
await page.evaluate(() => window.editorProbe.update({}));
|
||||||
|
await expect(cost).toHaveValue("999");
|
||||||
|
const snapshots = await page.evaluate((rates) => {
|
||||||
|
window.editorProbe.update({ data: { rates, activeRateId: "b" } });
|
||||||
|
return window.editorProbe.snapshots;
|
||||||
|
}, rates);
|
||||||
|
expect(snapshots.length).toBeGreaterThan(0);
|
||||||
|
for (const values of snapshots) {
|
||||||
|
expect(values).toContain("34");
|
||||||
|
expect(values).not.toContain("999");
|
||||||
|
}
|
||||||
|
await editor.getByRole("tab", { name: "Prévisions", exact: true }).click();
|
||||||
|
await expect(editor.getByRole("combobox", { name: "Période", exact: true })).toHaveValue("perDay");
|
||||||
|
await cost.fill("777");
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ moduleId: "other" }));
|
||||||
|
await expect(cost).toHaveValue("34");
|
||||||
|
await cost.fill("56");
|
||||||
|
await cost.press("Tab");
|
||||||
|
expect(await page.evaluate(() => window.editorProbe.saves.at(-1).rates[1].costPerDraw)).toBe("56");
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ editing: true }));
|
||||||
|
const label = editor.locator('.rng-simulator-rate-form input').first();
|
||||||
|
await label.fill("Unsubmitted");
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ editing: false }));
|
||||||
|
await expect(label).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ editing: true }));
|
||||||
|
await expect(label).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewer resets markers and drawing tools before committing a replacement image", async ({ page }) => {
|
||||||
|
const editor = await setup(page, "viewer");
|
||||||
|
const marker = editor.getByRole("textbox", { name: "Libellé du marqueur 1" });
|
||||||
|
await marker.fill("Edited");
|
||||||
|
await editor.getByTitle("Dessiner", { exact: true }).click();
|
||||||
|
await editor.getByTitle("Gommer", { exact: true }).click();
|
||||||
|
await page.evaluate(() => window.editorProbe.update({}));
|
||||||
|
await expect(marker).toHaveValue("Edited");
|
||||||
|
await expect(editor.getByTitle("Gommer", { exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
const snapshots = await page.evaluate(() => {
|
||||||
|
window.editorProbe.update({ image: {
|
||||||
|
...window.viewerImage, drawingMode: "temporary",
|
||||||
|
markers: [{ id: "b", label: "Second", x: 60, y: 60 }]
|
||||||
|
} });
|
||||||
|
return window.editorProbe.snapshots;
|
||||||
|
});
|
||||||
|
expect(snapshots.length).toBeGreaterThan(0);
|
||||||
|
for (const values of snapshots) expect(values).toEqual(["Second"]);
|
||||||
|
await expect(editor.getByTitle("Dessiner", { exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await editor.getByTitle("Dessiner", { exact: true }).click();
|
||||||
|
await expect(editor.getByTitle("Gommer", { exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(editor.getByRole("button", { name: "Annotations temporaires" })).toBeVisible();
|
||||||
|
await page.evaluate(() => window.editorProbe.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cover ignores obsolete decodes and releases every bitmap and object URL", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
const editor = await setup(page, "cover");
|
||||||
|
const save = editor.getByRole("button", { name: "Valider", exact: true });
|
||||||
|
await expect(save).toBeDisabled();
|
||||||
|
expect(await page.evaluate(() => window.decoding.pending.length)).toBe(2);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.decoding.resolve(0);
|
||||||
|
await window.decoding.resolve(1);
|
||||||
|
});
|
||||||
|
await expect(save).toBeEnabled();
|
||||||
|
await expect(editor.locator("img")).toBeVisible();
|
||||||
|
await editor.getByLabel("Zoom", { exact: true }).press("End");
|
||||||
|
await save.click();
|
||||||
|
expect(await page.evaluate(() => window.editorProbe.saves[0])).toMatch(/^data:image\/(webp|jpeg);base64,/);
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() }));
|
||||||
|
await expect(save).toBeDisabled();
|
||||||
|
await expect(editor.locator("img")).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() }));
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.decoding.resolve(3);
|
||||||
|
window.decoding.reject(2);
|
||||||
|
});
|
||||||
|
await expect(save).toBeEnabled();
|
||||||
|
await expect(editor.locator(".toolbox-cover-editor-error")).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() }));
|
||||||
|
await page.evaluate(() => window.decoding.reject(4));
|
||||||
|
await expect(save).toBeDisabled();
|
||||||
|
await expect(editor.locator(".toolbox-cover-editor-error")).toBeVisible();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.editorProbe.update({ file: window.probeFile.slice() });
|
||||||
|
window.editorProbe.unmount();
|
||||||
|
});
|
||||||
|
await page.evaluate(() => window.decoding.resolve(5));
|
||||||
|
const resources = await page.evaluate(() => {
|
||||||
|
window.decoding.restore();
|
||||||
|
return { created: window.decoding.created, revoked: window.decoding.revoked, closed: window.decoding.closed };
|
||||||
|
});
|
||||||
|
expect(resources.revoked.sort()).toEqual(resources.created.sort());
|
||||||
|
expect(resources.closed.sort()).toEqual([0, 1, 3, 5]);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cover loads an existing image and clears it when the source is removed", async ({ page }) => {
|
||||||
|
const editor = await setup(page, "cover", { existingCover: true });
|
||||||
|
await expect.poll(() => page.evaluate(() => window.decoding.pending.length)).toBe(2);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.decoding.resolve(0);
|
||||||
|
await window.decoding.resolve(1);
|
||||||
|
});
|
||||||
|
await expect(editor.getByRole("button", { name: "Valider", exact: true })).toBeEnabled();
|
||||||
|
await page.evaluate(() => window.editorProbe.update({ existingCover: "" }));
|
||||||
|
await expect(editor.locator("img")).toHaveCount(0);
|
||||||
|
await expect(editor.getByRole("button", { name: "Valider", exact: true })).toBeDisabled();
|
||||||
|
const resources = await page.evaluate(() => {
|
||||||
|
window.editorProbe.unmount();
|
||||||
|
window.decoding.restore();
|
||||||
|
return { created: window.decoding.created, closed: window.decoding.closed };
|
||||||
|
});
|
||||||
|
expect(resources.created).toEqual([]);
|
||||||
|
expect(resources.closed.sort()).toEqual([0, 1]);
|
||||||
|
});
|
||||||
44
e2e/fixtures/app-data-probe.js
Normal file
44
e2e/fixtures/app-data-probe.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Rôle : observe le chargement réel de l'application avec des réponses réseau contrôlées sous StrictMode.
|
||||||
|
import { createElement, StrictMode, useLayoutEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useAppData } from "../../website/src/hooks/useAppData.js";
|
||||||
|
|
||||||
|
export function mountAppDataProbe(element, initialRoute) {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const requests = [];
|
||||||
|
const snapshots = [];
|
||||||
|
const root = createRoot(element);
|
||||||
|
globalThis.fetch = (url, { signal } = {}) => new Promise((resolve) => {
|
||||||
|
// Le double ignore volontairement abort pour tester aussi les réponses déjà en cours.
|
||||||
|
requests.push({ url, signal, resolve, settled: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
function Probe({ route }) {
|
||||||
|
const data = useAppData(route);
|
||||||
|
useLayoutEffect(() => { snapshots.push(data); });
|
||||||
|
return createElement("pre", { id: "data-state" }, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(route) {
|
||||||
|
snapshots.length = 0;
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { route }))));
|
||||||
|
}
|
||||||
|
navigate(initialRoute);
|
||||||
|
return {
|
||||||
|
snapshots,
|
||||||
|
requests: () => requests.map(({ url, signal, settled }) => ({ url, aborted: signal?.aborted, settled })),
|
||||||
|
respond(url, payload, status = 200, aborted = false) {
|
||||||
|
for (const request of requests) {
|
||||||
|
if (request.url !== url || request.settled || request.signal?.aborted !== aborted) continue;
|
||||||
|
request.settled = true;
|
||||||
|
request.resolve({ ok: status >= 200 && status < 300, json: async () => payload });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigate,
|
||||||
|
unmount() {
|
||||||
|
flushSync(() => root.unmount());
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
32
e2e/fixtures/compiled-styles.js
Normal file
32
e2e/fixtures/compiled-styles.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Rôle : monte le CSS complet compilé et lit les styles calculés des fixtures visuelles.
|
||||||
|
import { compile } from "sass";
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
let css;
|
||||||
|
export async function mountStyles(page, html) {
|
||||||
|
css ??= compile("website/src/styles/main.scss").css;
|
||||||
|
await page.setContent(`<!doctype html>${html}`);
|
||||||
|
await page.addStyleTag({ content: css });
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computed(locator, property, pseudo = null) {
|
||||||
|
return locator.evaluate((element, { property, pseudo }) =>
|
||||||
|
element.ownerDocument.defaultView.getComputedStyle(element, pseudo).getPropertyValue(property),
|
||||||
|
{ property, pseudo });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectPseudo(locator, pseudo, property, value) {
|
||||||
|
await expect.poll(() => computed(locator, property, pseudo)).toEqual(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectFits(locator) {
|
||||||
|
await expect(locator).toBeVisible();
|
||||||
|
const viewport = locator.page().viewportSize();
|
||||||
|
await expect.poll(async () => (await locator.boundingBox()).x).toBeGreaterThanOrEqual(-1);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const bounds = await locator.boundingBox();
|
||||||
|
return bounds.x + bounds.width;
|
||||||
|
}).toBeLessThanOrEqual(viewport.width + 1);
|
||||||
|
expect(await locator.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
85
e2e/fixtures/editor-lifecycle-probe.js
Normal file
85
e2e/fixtures/editor-lifecycle-probe.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Rôle : monte les éditeurs réels sous StrictMode et contrôle les décodages concurrents.
|
||||||
|
/* global URL */
|
||||||
|
import { createElement, StrictMode, useLayoutEffect, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { ImageViewer } from "../../website/src/components/ImageViewer.jsx";
|
||||||
|
import { ToolboxCoverEditor } from "../../website/src/features/toolboxes/ToolboxCoverEditor.jsx";
|
||||||
|
import { RngSimulatorModule } from "../../website/src/features/toolboxes/modules/RngSimulatorModule.jsx";
|
||||||
|
import { normalizeRngSimulatorData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
|
export function mountEditorProbe(element, kind, initialProps) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const snapshots = [];
|
||||||
|
const saves = [];
|
||||||
|
let updateProps;
|
||||||
|
const components = { viewer: ImageViewer, cover: ToolboxCoverEditor, rng: RngSimulatorModule };
|
||||||
|
function Probe() {
|
||||||
|
const [props, setProps] = useState(initialProps);
|
||||||
|
updateProps = setProps;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
snapshots.push(Array.from(element.querySelectorAll("input"), (input) => input.value));
|
||||||
|
});
|
||||||
|
const context = {
|
||||||
|
getModuleData: () => props.data,
|
||||||
|
normalizeRngSimulatorData,
|
||||||
|
setModuleData: (_toolbox, _module, data) => {
|
||||||
|
saves.push(data);
|
||||||
|
setProps((current) => ({ ...current, data }));
|
||||||
|
},
|
||||||
|
uid: () => "new-rate"
|
||||||
|
};
|
||||||
|
return createElement(components[kind], {
|
||||||
|
toolboxId: "probe", moduleId: "rng", context,
|
||||||
|
onClose: () => {}, onSave: (value) => saves.push(value),
|
||||||
|
setEditing: (editing) => setProps((current) => ({ ...current, editing })),
|
||||||
|
...props
|
||||||
|
});
|
||||||
|
}
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
|
||||||
|
return {
|
||||||
|
snapshots, saves,
|
||||||
|
update(patch) {
|
||||||
|
snapshots.length = 0;
|
||||||
|
flushSync(() => updateProps((props) => ({ ...props, ...patch })));
|
||||||
|
},
|
||||||
|
unmount: () => flushSync(() => root.unmount())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function controlImageDecoding() {
|
||||||
|
const originalDecode = globalThis.createImageBitmap;
|
||||||
|
const originalCreate = URL.createObjectURL;
|
||||||
|
const originalRevoke = URL.revokeObjectURL;
|
||||||
|
const pending = [];
|
||||||
|
const created = [];
|
||||||
|
const revoked = [];
|
||||||
|
const closed = [];
|
||||||
|
globalThis.createImageBitmap = (file) => new Promise((resolve, reject) => {
|
||||||
|
pending.push({ file, resolve, reject });
|
||||||
|
});
|
||||||
|
URL.createObjectURL = (file) => {
|
||||||
|
const url = originalCreate(file);
|
||||||
|
created.push(url);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
URL.revokeObjectURL = (url) => {
|
||||||
|
revoked.push(url);
|
||||||
|
originalRevoke(url);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
pending, created, revoked, closed,
|
||||||
|
async resolve(index) {
|
||||||
|
const bitmap = await originalDecode(pending[index].file);
|
||||||
|
const close = bitmap.close.bind(bitmap);
|
||||||
|
bitmap.close = () => { closed.push(index); close(); };
|
||||||
|
pending[index].resolve(bitmap);
|
||||||
|
},
|
||||||
|
reject: (index) => pending[index].reject(new Error("decode failed")),
|
||||||
|
restore() {
|
||||||
|
globalThis.createImageBitmap = originalDecode;
|
||||||
|
URL.createObjectURL = originalCreate;
|
||||||
|
URL.revokeObjectURL = originalRevoke;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
48
e2e/fixtures/form-reorder-probe.js
Normal file
48
e2e/fixtures/form-reorder-probe.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// Rôle : expose les hooks réels de formulaire et déplacement sous StrictMode pour les tests navigateur.
|
||||||
|
import { createElement as h, StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useDraftForm } from "../../website/src/hooks/useDraftForm.js";
|
||||||
|
import { usePointerReorder } from "../../website/src/hooks/usePointerReorder.js";
|
||||||
|
|
||||||
|
export function mountFormReorderProbe(element, kind) {
|
||||||
|
const events = [];
|
||||||
|
const root = createRoot(element);
|
||||||
|
function Form() {
|
||||||
|
const form = useDraftForm({ title: "Initial", detail: "Kept" });
|
||||||
|
return h("form", { onSubmit: (event) => {
|
||||||
|
form.handleSubmit(event, (values, { reset }) => {
|
||||||
|
events.push({ ...values });
|
||||||
|
if (!values.title.trim()) return "Required";
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
events.push({ prevented: event.defaultPrevented });
|
||||||
|
} },
|
||||||
|
h("input", form.getFieldProps("title", { "aria-label": "Title", onChange: () => events.push("changed") })),
|
||||||
|
h("input", form.getFieldProps("detail", { "aria-label": "Detail" })),
|
||||||
|
h("p", { role: "alert" }, form.error),
|
||||||
|
h("button", { type: "submit" }, "Submit"),
|
||||||
|
h("button", { type: "button", onClick: () => form.updateValues({ title: "Patched" }) }, "Patch"),
|
||||||
|
h("button", { type: "button", onClick: () => form.reset({ title: "Replacement", detail: "New" }) }, "Replace"));
|
||||||
|
}
|
||||||
|
function Reorder({ revision }) {
|
||||||
|
const reorder = usePointerReorder({
|
||||||
|
targetSelector: "[data-probe-item]",
|
||||||
|
getTargetId: (element) => element.dataset.probeItem,
|
||||||
|
canDropOn: (element) => element.dataset.probeItem !== "blocked",
|
||||||
|
onMove: (source, target, placement) => events.push({ source, target, placement, revision })
|
||||||
|
});
|
||||||
|
return h("section", null,
|
||||||
|
h("output", { "aria-label": "Dragging", style: { display: "block", height: 24 } }, reorder.draggingId),
|
||||||
|
...["a", "b", "blocked"].map((id) => h("div", {
|
||||||
|
key: id, "data-probe-item": id,
|
||||||
|
style: { height: 100, width: 300, border: "1px solid", margin: 10 }
|
||||||
|
}, h("button", { type: "button", onPointerDown: (event) => reorder.startDrag(event, id) }, id))));
|
||||||
|
}
|
||||||
|
const Component = kind === "form" ? Form : Reorder;
|
||||||
|
function update(revision = 0) {
|
||||||
|
flushSync(() => root.render(h(StrictMode, null, h(Component, { revision }))));
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
return { events, update, unmount: () => root.unmount() };
|
||||||
|
}
|
||||||
45
e2e/fixtures/goal-selection-probe.js
Normal file
45
e2e/fixtures/goal-selection-probe.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Rôle : observe les sélections et onglets réellement validés par le contrôleur Goal Tree.
|
||||||
|
import { createElement, StrictMode, useLayoutEffect, useMemo } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useGoalTreeController } from "../../website/src/features/toolboxes/modules/useGoalTreeController.js";
|
||||||
|
import { normalizeGoalTreeData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
|
export function mountGoalSelectionProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const snapshots = [];
|
||||||
|
let controller;
|
||||||
|
|
||||||
|
function Probe({ nodes }) {
|
||||||
|
const context = useMemo(() => ({
|
||||||
|
getModuleData: () => ({ nodes }),
|
||||||
|
normalizeGoalTreeData,
|
||||||
|
setModuleData: () => {}
|
||||||
|
}), [nodes]);
|
||||||
|
const state = useGoalTreeController({
|
||||||
|
toolboxId: "probe", moduleId: "goal", context,
|
||||||
|
editing: true, setEditing: () => {}, openEditor: () => {}
|
||||||
|
});
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
controller = state;
|
||||||
|
snapshots.push({
|
||||||
|
selected: state.selectedNode?.id || "",
|
||||||
|
tab: state.activeFormTab,
|
||||||
|
tabs: state.formTabs.map((tab) => tab.id)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return createElement("output", null, state.selectedNode?.title || "Empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(nodes) {
|
||||||
|
snapshots.length = 0;
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { nodes }))));
|
||||||
|
}
|
||||||
|
update([]);
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
snapshots,
|
||||||
|
edit: (id) => flushSync(() => controller.editNode(id)),
|
||||||
|
unmount: () => root.unmount()
|
||||||
|
};
|
||||||
|
}
|
||||||
49
e2e/fixtures/grouped-reorder-probe.js
Normal file
49
e2e/fixtures/grouped-reorder-probe.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// Rôle : expose la politique réelle de réorganisation dans une géométrie stable sous StrictMode.
|
||||||
|
import { createElement as h, StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useGroupedReorder } from "../../website/src/hooks/useGroupedReorder.js";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ id: "a", group: "A", parent: "p" }, { id: "b", group: "A", parent: "p" },
|
||||||
|
{ id: "c", group: "B", parent: "p" }, { id: "d", group: "B", parent: "q" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function mountGroupedReorderProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const events = [];
|
||||||
|
let options = {
|
||||||
|
orientation: "vertical", hierarchy: { enabled: true, stickyParents: true },
|
||||||
|
reorderFeatures: { item: { groupChange: true }, group: { reorder: true } }, veto: false
|
||||||
|
};
|
||||||
|
function Probe({ config }) {
|
||||||
|
const reorder = useGroupedReorder({
|
||||||
|
...config, namespace: "probe", items,
|
||||||
|
getItemGroup: (item) => item.group, getParentId: (item) => item.parent,
|
||||||
|
canMoveItem: () => !config.veto, canMoveGroup: () => !config.veto,
|
||||||
|
onItemMove: (operation) => events.push(operation), onGroupMove: (operation) => events.push(operation)
|
||||||
|
});
|
||||||
|
const tile = (id, props, child) => h("div", {
|
||||||
|
...props, key: id, "data-testid": id,
|
||||||
|
style: { height: 96, width: 220, border: "1px solid" }
|
||||||
|
}, child);
|
||||||
|
return h("section", null,
|
||||||
|
h("output", { "aria-label": "Dragging", style: { display: "block", height: 24 } },
|
||||||
|
reorder.itemReorder.draggingId || reorder.groupReorder.draggingId ? "active" : ""),
|
||||||
|
h("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, 222px)", gap: 12 } },
|
||||||
|
...items.map((item) => tile(item.id, reorder.getItemProps({ itemId: item.id, groupId: item.group, parentId: item.parent }),
|
||||||
|
h("button", { type: "button", onPointerDown: (event) => reorder.itemReorder.startDrag(event, item.id) }, `Item ${item.id}`))),
|
||||||
|
...["A", "B"].map((groupId) => tile(`group-${groupId}`, reorder.getGroupProps({ groupId, parentId: "p" }),
|
||||||
|
h("button", { type: "button", onPointerDown: (event) => reorder.groupReorder.startDrag(event, { groupId, parentId: "p" }) }, `Group ${groupId}`))),
|
||||||
|
tile("boundary", reorder.getGroupBoundaryProps({ groupId: "B", parentId: "p", placement: "after" }), "Boundary"),
|
||||||
|
tile("ungrouped", reorder.getUngroupedDropProps({ parentId: "p" }), "Ungrouped"),
|
||||||
|
tile("alien", { ...reorder.getItemProps({ itemId: "alien", groupId: "A", parentId: "p" }), "data-reorder-namespace": "other" }, "Other namespace")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
function update(patch = {}) {
|
||||||
|
options = { ...options, ...patch };
|
||||||
|
flushSync(() => root.render(h(StrictMode, null, h(Probe, { config: options }))));
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
return { events, update, unmount: () => root.unmount() };
|
||||||
|
}
|
||||||
34
e2e/fixtures/inline-edit-probe.js
Normal file
34
e2e/fixtures/inline-edit-probe.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Rôle : monte le hook d'édition réel sous StrictMode pour observer ses commits DOM.
|
||||||
|
import { createElement, StrictMode, useLayoutEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useInlineEdit } from "../../website/src/hooks/useInlineEdit.js";
|
||||||
|
|
||||||
|
export function mountInlineEditProbe(element, contentEditable) {
|
||||||
|
const commits = [];
|
||||||
|
const renders = [];
|
||||||
|
const root = createRoot(element);
|
||||||
|
|
||||||
|
function Probe({ value }) {
|
||||||
|
const edit = useInlineEdit({
|
||||||
|
value,
|
||||||
|
onCommit: (next) => commits.push(next),
|
||||||
|
blurOnEscape: true
|
||||||
|
});
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
renders.push({ value, draft: edit.draft });
|
||||||
|
});
|
||||||
|
return createElement("section", null,
|
||||||
|
contentEditable
|
||||||
|
? createElement("div", edit.getContentEditableProps({ role: "textbox", "aria-label": "Edit" }))
|
||||||
|
: createElement("input", edit.getInputProps({ type: "text", "aria-label": "Edit" })),
|
||||||
|
createElement("button", { type: "button" }, "Outside")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(value) {
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { value }))));
|
||||||
|
}
|
||||||
|
update("Initial");
|
||||||
|
return { update, commits, renders, unmount: () => root.unmount() };
|
||||||
|
}
|
||||||
38
e2e/fixtures/keyed-state-probe.js
Normal file
38
e2e/fixtures/keyed-state-probe.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Rôle : observe les valeurs validées et les setters différés d'un état lié à une clé.
|
||||||
|
/* global sessionStorage */
|
||||||
|
import { createElement, StrictMode, useLayoutEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useKeyedState } from "../../website/src/hooks/useKeyedState.js";
|
||||||
|
|
||||||
|
export function mountKeyedStateProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const snapshots = [];
|
||||||
|
const setters = new Map();
|
||||||
|
let reads = 0;
|
||||||
|
|
||||||
|
function Probe({ identity }) {
|
||||||
|
const [value, setValue] = useKeyedState(identity, (key) => {
|
||||||
|
reads++;
|
||||||
|
return JSON.parse(sessionStorage.getItem(`keyed-probe:${key}`) || "[]");
|
||||||
|
});
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
snapshots.push({ key: identity, value });
|
||||||
|
setters.set(identity, setValue);
|
||||||
|
});
|
||||||
|
return createElement("output", null, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(identity) {
|
||||||
|
snapshots.length = 0;
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { identity }))));
|
||||||
|
}
|
||||||
|
update("a");
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
snapshots,
|
||||||
|
get reads() { return reads; },
|
||||||
|
append: (key, value) => flushSync(() => setters.get(key)((current) => [...current, value])),
|
||||||
|
unmount: () => root.unmount()
|
||||||
|
};
|
||||||
|
}
|
||||||
45
e2e/fixtures/notepad-sync-probe.js
Normal file
45
e2e/fixtures/notepad-sync-probe.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Rôle : pilote les mises à jour locales et externes du Bloc Note réel sous StrictMode.
|
||||||
|
import { createElement, StrictMode, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { NotepadModule } from "../../website/src/features/toolboxes/modules/NotepadModule.jsx";
|
||||||
|
import { normalizeNotepadData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
|
export function mountNotepadSyncProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const writes = [];
|
||||||
|
let setInputs;
|
||||||
|
let inputs;
|
||||||
|
function Probe() {
|
||||||
|
const [value, setValue] = useState({
|
||||||
|
moduleId: "notes",
|
||||||
|
data: { activePageId: "a", pages: [
|
||||||
|
{ id: "a", title: "First", html: "<p>Alpha</p>" },
|
||||||
|
{ id: "b", title: "Second", html: "<p>Bravo</p>", drawingMode: "permanent" }
|
||||||
|
] }
|
||||||
|
});
|
||||||
|
inputs = value;
|
||||||
|
setInputs = setValue;
|
||||||
|
const context = {
|
||||||
|
getModuleData: () => value.data,
|
||||||
|
normalizeNotepadData,
|
||||||
|
uid: () => "new-page",
|
||||||
|
setModuleData: (_toolbox, moduleId, data) => {
|
||||||
|
writes.push({ moduleId, data });
|
||||||
|
setValue((current) => ({ ...current, data }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return createElement(NotepadModule, { toolboxId: "probe", moduleId: value.moduleId, context });
|
||||||
|
}
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
|
||||||
|
return {
|
||||||
|
writes,
|
||||||
|
read: () => inputs,
|
||||||
|
update: (patch) => flushSync(() => setInputs((current) => ({ ...current, ...patch }))),
|
||||||
|
patchPage: (id, patch) => flushSync(() => setInputs((current) => ({
|
||||||
|
...current, data: { ...current.data, pages: current.data.pages.map((page) => page.id === id ? { ...page, ...patch } : page) }
|
||||||
|
}))),
|
||||||
|
select: (id) => flushSync(() => setInputs((current) => ({ ...current, data: { ...current.data, activePageId: id } }))),
|
||||||
|
unmount: () => flushSync(() => root.unmount())
|
||||||
|
};
|
||||||
|
}
|
||||||
21
e2e/fixtures/shell-probe.js
Normal file
21
e2e/fixtures/shell-probe.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Rôle : monte les vrais overlays et textes éditoriaux sous StrictMode pour tester leur cycle de vie.
|
||||||
|
import { createElement, StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { ConfirmModal, CreateToolboxModal, NotificationToast } from "../../website/src/components/ToolboxModals.jsx";
|
||||||
|
import { RichText } from "../../website/src/components/RichText.jsx";
|
||||||
|
|
||||||
|
export function mountShellProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const closed = [];
|
||||||
|
const onClose = (value) => closed.push(value);
|
||||||
|
function render({ confirm = false, create = false, toast = null, text = "" } = {}) {
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null,
|
||||||
|
confirm && createElement(ConfirmModal, { key: "confirm", title: "Confirmation test", message: "Continuer ?", onClose }),
|
||||||
|
create && createElement(CreateToolboxModal, { key: "create", onClose }),
|
||||||
|
toast && createElement(NotificationToast, { key: "toast", ...toast, onClose }),
|
||||||
|
createElement("div", { id: "editorial-text", key: "text" }, createElement(RichText, { text }))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return { render, closed, unmount: () => root.unmount() };
|
||||||
|
}
|
||||||
20
e2e/fixtures/storage-startup-probe.js
Normal file
20
e2e/fixtures/storage-startup-probe.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Rôle : expose les commandes du vrai stockage et des actions sous StrictMode pour tester leur verrouillage initial.
|
||||||
|
import { createElement, StrictMode, useLayoutEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useIndexedToolboxes } from "../../website/src/features/toolboxes/storage/useIndexedToolboxes.js";
|
||||||
|
import { useToolboxActions } from "../../website/src/features/toolboxes/useToolboxActions.js";
|
||||||
|
|
||||||
|
export function mountStorageStartupProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const api = { store: null, actions: null, notices: [] };
|
||||||
|
const notify = (message) => api.notices.push(message);
|
||||||
|
function Probe() {
|
||||||
|
const store = useIndexedToolboxes(notify);
|
||||||
|
const actions = useToolboxActions({ store, notify, setConfirmModal: notify });
|
||||||
|
useLayoutEffect(() => { api.store = store; api.actions = actions; });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
|
||||||
|
return { ...api, get store() { return api.store; }, get actions() { return api.actions; }, unmount: () => root.unmount() };
|
||||||
|
}
|
||||||
33
e2e/fixtures/toolbox-export.json
Normal file
33
e2e/fixtures/toolbox-export.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"toolbox": {
|
||||||
|
"id": "fixture-toolbox",
|
||||||
|
"name": "Fixture Toolbox",
|
||||||
|
"updatedAt": "2026-08-24T10:00:00.000Z",
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "fixture-note",
|
||||||
|
"type": "notepad",
|
||||||
|
"title": "Fixture Notes"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"moduleColumns": 1,
|
||||||
|
"moduleOrder": {
|
||||||
|
"one": [
|
||||||
|
"fixture-note"
|
||||||
|
],
|
||||||
|
"two": [
|
||||||
|
[
|
||||||
|
"fixture-note"
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"fixture-note": {
|
||||||
|
"html": "<p>Imported fixture note</p>",
|
||||||
|
"text": "Imported fixture note",
|
||||||
|
"updatedAt": "2026-08-24T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
e2e/fixtures/toolbox-tutorial-probe.js
Normal file
51
e2e/fixtures/toolbox-tutorial-probe.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Rôle : monte la page toolbox sous StrictMode pour contrôler les demandes de tutoriel et les changements d'identité.
|
||||||
|
import { createElement, StrictMode, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { ToolboxPage } from "../../website/src/features/toolboxes/ToolboxPages.jsx";
|
||||||
|
|
||||||
|
export function mountToolboxTutorialProbe(element, siteContent) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const writes = [];
|
||||||
|
const pendingChanges = [];
|
||||||
|
let setInputs;
|
||||||
|
function createToolbox(id) {
|
||||||
|
const defaultSpaceId = `space-${id}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
modules: [],
|
||||||
|
spaces: [{ id: defaultSpaceId, name: "Défaut", moduleOrder: { one: [], two: [[], []] } }],
|
||||||
|
defaultSpaceId,
|
||||||
|
activeSpaceId: defaultSpaceId,
|
||||||
|
tutorial: id !== "c"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function Probe() {
|
||||||
|
const [inputs, updateInputs] = useState({
|
||||||
|
toolboxId: "a", pendingTutorialToolboxId: "",
|
||||||
|
toolboxes: ["a", "b", "c"].map(createToolbox)
|
||||||
|
});
|
||||||
|
setInputs = updateInputs;
|
||||||
|
return createElement(ToolboxPage, {
|
||||||
|
...inputs, siteContent, actions: {},
|
||||||
|
getToolboxGame: () => null,
|
||||||
|
getModuleData: (_toolbox, _module, fallback) => fallback,
|
||||||
|
updateModuleData: () => {},
|
||||||
|
updateToolbox: (toolbox) => {
|
||||||
|
writes.push(toolbox);
|
||||||
|
updateInputs((current) => ({ ...current, toolboxes: current.toolboxes.map((item) => item.id === toolbox.id ? toolbox : item) }));
|
||||||
|
},
|
||||||
|
setPendingTutorialToolboxId: (id) => {
|
||||||
|
pendingChanges.push(id);
|
||||||
|
updateInputs((current) => ({ ...current, pendingTutorialToolboxId: id }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
|
||||||
|
return {
|
||||||
|
writes, pendingChanges,
|
||||||
|
update: (patch) => flushSync(() => setInputs((current) => ({ ...current, ...patch }))),
|
||||||
|
unmount: () => flushSync(() => root.unmount())
|
||||||
|
};
|
||||||
|
}
|
||||||
92
e2e/form-reorder.spec.js
Normal file
92
e2e/form-reorder.spec.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// Rôle : vérifie les brouillons de formulaire et le cycle de déplacement par pointeur sans assertions de code source.
|
||||||
|
/* global window, document */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/form-reorder-probe.js", import.meta.url))}`;
|
||||||
|
async function setup(page, kind) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async ({ url, kind }) => {
|
||||||
|
const { mountFormReorderProbe } = await import(url);
|
||||||
|
document.getElementById("app").style.display = "none";
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.prepend(element);
|
||||||
|
window.interactionProbe = mountFormReorderProbe(element, kind);
|
||||||
|
}, { url: probeUrl, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("draft forms retain edits, report validation, prevent navigation and reset after success", async ({ page }) => {
|
||||||
|
await setup(page, "form");
|
||||||
|
const title = page.getByRole("textbox", { name: "Title", exact: true });
|
||||||
|
const detail = page.getByRole("textbox", { name: "Detail", exact: true });
|
||||||
|
await title.fill("");
|
||||||
|
await page.getByRole("button", { name: "Submit", exact: true }).click();
|
||||||
|
await expect(page.getByRole("alert")).toHaveText("Required");
|
||||||
|
await title.fill("Draft");
|
||||||
|
await expect(page.getByRole("alert")).toBeEmpty();
|
||||||
|
await page.evaluate(() => window.interactionProbe.update(1));
|
||||||
|
await expect(title).toHaveValue("Draft");
|
||||||
|
await expect(detail).toHaveValue("Kept");
|
||||||
|
await page.getByRole("button", { name: "Patch", exact: true }).click();
|
||||||
|
await expect(title).toHaveValue("Patched");
|
||||||
|
await expect(detail).toHaveValue("Kept");
|
||||||
|
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||||
|
await expect(title).toHaveValue("Replacement");
|
||||||
|
await expect(detail).toHaveValue("New");
|
||||||
|
await title.press("Enter");
|
||||||
|
await expect(title).toHaveValue("Initial");
|
||||||
|
await expect(detail).toHaveValue("Kept");
|
||||||
|
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([
|
||||||
|
"changed", { title: "", detail: "Kept" }, { prevented: true },
|
||||||
|
"changed", { title: "Replacement", detail: "New" }, { prevented: true }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start(page) {
|
||||||
|
await page.getByRole("button", { name: "a", exact: true }).hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.getByLabel("Dragging")).toHaveText("a");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("pointer drops resolve before and after using the latest callback and reject invalid targets", async ({ page }) => {
|
||||||
|
await setup(page, "reorder");
|
||||||
|
await page.getByRole("button", { name: "a", exact: true }).hover();
|
||||||
|
await page.mouse.down({ button: "right" });
|
||||||
|
await expect(page.getByLabel("Dragging")).toBeEmpty();
|
||||||
|
await page.mouse.up({ button: "right" });
|
||||||
|
const target = await page.locator('[data-probe-item="b"]').boundingBox();
|
||||||
|
for (const [offset, placement] of [[10, "before"], [90, "after"]]) {
|
||||||
|
await start(page);
|
||||||
|
await page.evaluate(() => window.interactionProbe.update(1));
|
||||||
|
await page.mouse.move(target.x + 200, target.y + offset);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByLabel("Dragging")).toBeEmpty();
|
||||||
|
expect(await page.evaluate(() => window.interactionProbe.events.at(-1))).toEqual({ source: "a", target: "b", placement, revision: 1 });
|
||||||
|
}
|
||||||
|
await start(page);
|
||||||
|
await page.locator('[data-probe-item="blocked"]').hover();
|
||||||
|
await page.mouse.up();
|
||||||
|
await start(page);
|
||||||
|
await page.mouse.up();
|
||||||
|
expect(await page.evaluate(() => window.interactionProbe.events.length)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pointer cancellation never commits a drop and unmount removes active listeners", async ({ page }) => {
|
||||||
|
await setup(page, "reorder");
|
||||||
|
const target = await page.locator('[data-probe-item="b"]').boundingBox();
|
||||||
|
await start(page);
|
||||||
|
await page.mouse.move(target.x + 200, target.y + 50);
|
||||||
|
await page.getByRole("button", { name: "a", exact: true }).dispatchEvent("pointercancel", { clientX: target.x + 200, clientY: target.y + 50, pointerId: 1 });
|
||||||
|
await expect(page.getByLabel("Dragging")).toBeEmpty();
|
||||||
|
await page.mouse.up();
|
||||||
|
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]);
|
||||||
|
await start(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const remainingTarget = document.querySelector('[data-probe-item="b"]').cloneNode(true);
|
||||||
|
window.interactionProbe.unmount();
|
||||||
|
document.body.prepend(remainingTarget);
|
||||||
|
});
|
||||||
|
await page.locator('[data-probe-item="b"]').hover();
|
||||||
|
await page.mouse.up();
|
||||||
|
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]);
|
||||||
|
});
|
||||||
112
e2e/game-filters.spec.js
Normal file
112
e2e/game-filters.spec.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// Rôle : vérifie les variantes horizontales compactes et avancées des filtres de jeux.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
for (const { game, category, width } of [
|
||||||
|
{ game: "diablo4/affixes", category: "Catégories", width: 1920 },
|
||||||
|
{ game: "diablo4/affixes", category: "Catégories", width: 900 },
|
||||||
|
{ game: "diablo4/affixes", category: "Catégories", width: 768 },
|
||||||
|
{ game: "aniimo/aniimos", category: "Rôles", width: 1920 }
|
||||||
|
]) {
|
||||||
|
test(`${game} dropdown receives clicks over active badges at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.goto(`/#/games/${game}`);
|
||||||
|
const filters = page.locator('.nds-filter-panel[data-variant="horizontal-menu"]');
|
||||||
|
await filters.getByRole("searchbox").fill("a");
|
||||||
|
const badges = filters.locator(".nds-active-filters");
|
||||||
|
await expect(badges).toBeVisible();
|
||||||
|
await filters.getByRole("button", { name: category, exact: true }).click();
|
||||||
|
const menu = page.getByRole("dialog", { name: new RegExp(`^${category}`) });
|
||||||
|
const control = category === "Catégories"
|
||||||
|
? menu.getByRole("radio", { name: "Ou", exact: true })
|
||||||
|
: menu.getByRole("checkbox").first();
|
||||||
|
const controlBox = await control.boundingBox();
|
||||||
|
const badgesBox = await badges.boundingBox();
|
||||||
|
const overlapTop = Math.max(controlBox.y, badgesBox.y);
|
||||||
|
const overlapBottom = Math.min(controlBox.y + controlBox.height, badgesBox.y + badgesBox.height);
|
||||||
|
expect(overlapBottom).toBeGreaterThan(overlapTop);
|
||||||
|
|
||||||
|
await control.click({
|
||||||
|
position: { x: controlBox.width / 2, y: (overlapTop + overlapBottom) / 2 - controlBox.y },
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
if (category === "Catégories") await expect(control).toHaveAttribute("aria-checked", "true");
|
||||||
|
else await expect(control).toBeChecked();
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await badges.getByRole("button").first().click();
|
||||||
|
await expect(filters.getByRole("searchbox")).toHaveValue("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [1920, 900]) {
|
||||||
|
test(`MHW compact filters keep accessible tooltips at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.goto("/#/games/mhwilds/monsters");
|
||||||
|
const filters = page.locator('.nds-filter-panel[data-variant="horizontal-compact"]');
|
||||||
|
await expect(filters).toBeVisible();
|
||||||
|
const options = filters.locator(".nds-filter-option");
|
||||||
|
await expect(options).toHaveCount(5);
|
||||||
|
const optionWidths = await options.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().width));
|
||||||
|
if (width > 1040) expect(Math.max(...optionWidths) - Math.min(...optionWidths)).toBeGreaterThan(1);
|
||||||
|
else expect(Math.max(...optionWidths) - Math.min(...optionWidths)).toBeLessThan(1);
|
||||||
|
const first = options.first();
|
||||||
|
await expect(first).toHaveAttribute("aria-label", /.+/);
|
||||||
|
await first.hover();
|
||||||
|
await expect(page.getByRole("tooltip")).toBeVisible();
|
||||||
|
const label = first.locator(".nds-filter-option-label");
|
||||||
|
await expect(label).toHaveCSS("position", width <= 1040 ? "absolute" : "static");
|
||||||
|
await first.click();
|
||||||
|
await expect(first).toHaveAttribute("aria-pressed", "true");
|
||||||
|
const activeBadge = page.locator(".nds-active-filter").first();
|
||||||
|
await expect(activeBadge).toBeVisible();
|
||||||
|
const activeBar = page.locator(".nds-active-filters");
|
||||||
|
await expect(activeBar).toHaveCSS("border-top-style", "solid");
|
||||||
|
await expect(activeBar).toHaveCSS("border-top-color", "rgba(246, 196, 83, 0.3)");
|
||||||
|
await expect(activeBar).toHaveCSS("padding-top", "4px");
|
||||||
|
await expect(activeBar).toHaveCSS("margin-bottom", "-6px");
|
||||||
|
await expect(activeBar.locator("xpath=..")).toHaveClass(/nds-filter-panel/);
|
||||||
|
await expect(activeBar.locator("xpath=..")).toHaveCSS("row-gap", "10px");
|
||||||
|
const panelBox = await filters.boundingBox();
|
||||||
|
const activeBarBox = await activeBar.boundingBox();
|
||||||
|
expect(activeBarBox.x).toBeCloseTo(panelBox.x + 1, 0);
|
||||||
|
expect(activeBarBox.x + activeBarBox.width).toBeCloseTo(panelBox.x + panelBox.width - 1, 0);
|
||||||
|
expect((await activeBar.boundingBox()).height).toBeLessThanOrEqual(30);
|
||||||
|
await activeBadge.click();
|
||||||
|
await expect(first).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(page.locator(".game-filters")).toHaveCSS("position", width <= 760 ? "static" : "sticky");
|
||||||
|
await expect(page.locator(".game-filters")).toHaveCSS("top", width <= 760 ? "auto" : "68px");
|
||||||
|
expect((await filters.locator(".nds-filter-search").boundingBox())?.width).toBeLessThanOrEqual(260);
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Diablo advanced categories keep text visible and retain focus on Escape", async ({ page }) => {
|
||||||
|
await page.goto("/#/games/diablo4/affixes");
|
||||||
|
const filters = page.locator('.nds-filter-panel[data-variant="horizontal-menu"]');
|
||||||
|
const trigger = filters.getByRole("button", { name: /^Catégories/ });
|
||||||
|
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(await trigger.locator("span").first().evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
await trigger.click();
|
||||||
|
const menu = page.getByRole("dialog", { name: /^Catégories/ });
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
await expect(menu.getByText("Correspondance", { exact: true })).toBeVisible();
|
||||||
|
await expect(menu.getByRole("radio", { name: "Et", exact: true })).toHaveAttribute("aria-checked", "true");
|
||||||
|
await expect(filters.locator(":scope > .nds-filter-group > .nds-filter-logic")).toHaveCount(0);
|
||||||
|
await expect(menu.locator(".nds-filter-logic")).toHaveCSS("margin-bottom", "12px");
|
||||||
|
await expect(menu.locator(".nds-filter-menu-options")).toHaveCSS("overflow-y", "visible");
|
||||||
|
await expect(filters).toHaveCSS("overflow", "visible");
|
||||||
|
const filtersBox = await filters.boundingBox();
|
||||||
|
const menuBox = await menu.boundingBox();
|
||||||
|
expect(filtersBox).not.toBeNull();
|
||||||
|
expect(menuBox).not.toBeNull();
|
||||||
|
expect(menuBox.y + menuBox.height).toBeGreaterThan(filtersBox.y + filtersBox.height);
|
||||||
|
await expect(menu.getByRole("checkbox")).toHaveCount(8);
|
||||||
|
await expect(menu.getByText("Offensif", { exact: true })).toBeVisible();
|
||||||
|
await expect(menu.getByText("Mobilité", { exact: true })).toBeVisible();
|
||||||
|
await menu.getByRole("checkbox", { name: "Offensif", exact: true }).check();
|
||||||
|
await expect(trigger.locator(".nds-filter-count")).toHaveText("1");
|
||||||
|
await expect(page.getByRole("button", { name: "Retirer le filtre Catégories : Offensif", exact: true })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
});
|
||||||
44
e2e/goal-selection.spec.js
Normal file
44
e2e/goal-selection.spec.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Rôle : couvre les remplacements de données et les onglets invalides du Goal Tree sous StrictMode.
|
||||||
|
/* global window, document */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/goal-selection-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
test("goal selection remains valid through kind changes and data replacement", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountGoalSelectionProbe } = await import(url);
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.prepend(element);
|
||||||
|
window.goalProbe = mountGoalSelectionProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
const first = { id: "first", title: "First", kind: "checklist" };
|
||||||
|
const second = { id: "second", title: "Second", kind: "checklist" };
|
||||||
|
|
||||||
|
async function updateAndCheck(nodes, selected, tab) {
|
||||||
|
const snapshots = await page.evaluate((nodes) => {
|
||||||
|
window.goalProbe.update(nodes);
|
||||||
|
return window.goalProbe.snapshots;
|
||||||
|
}, nodes);
|
||||||
|
expect(snapshots.length).toBeGreaterThan(0);
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
expect(snapshot.selected).toBe(selected);
|
||||||
|
expect(snapshot.tab).toBe(tab);
|
||||||
|
expect(snapshot.tabs).toContain(snapshot.tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAndCheck([first, second], "first", "base");
|
||||||
|
await page.evaluate(() => window.goalProbe.edit("second"));
|
||||||
|
expect(await page.evaluate(() => window.goalProbe.snapshots.at(-1).tab)).toBe("content");
|
||||||
|
await updateAndCheck([first, { ...second, kind: "step" }], "second", "base");
|
||||||
|
await updateAndCheck([first], "first", "base");
|
||||||
|
await updateAndCheck([first, second], "first", "base");
|
||||||
|
await updateAndCheck([], "", "base");
|
||||||
|
await updateAndCheck([second], "second", "base");
|
||||||
|
await page.evaluate(() => window.goalProbe.unmount());
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
74
e2e/grouped-reorder.spec.js
Normal file
74
e2e/grouped-reorder.spec.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Rôle : vérifie les contraintes de groupes, de parents et d'orientation avec de vrais gestes pointeur.
|
||||||
|
/* global window, document */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/grouped-reorder-probe.js", import.meta.url))}`;
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountGroupedReorderProbe } = await import(url);
|
||||||
|
document.getElementById("app").style.display = "none";
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.prepend(element);
|
||||||
|
window.groupedProbe = mountGroupedReorderProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function configure(page, options) {
|
||||||
|
await page.evaluate((options) => window.groupedProbe.update(options), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drop(page, source, target, x = 0.2, y = 0.8) {
|
||||||
|
await page.getByRole("button", { name: source, exact: true }).hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.getByLabel("Dragging")).toHaveText("active");
|
||||||
|
const box = await page.getByTestId(target).boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width * x, box.y + box.height * y, { steps: 3 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByLabel("Dragging")).toBeEmpty();
|
||||||
|
return page.evaluate(() => window.groupedProbe.events.splice(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("item moves obey parent boundaries, group changes, namespace isolation and custom vetoes", async ({ page }) => {
|
||||||
|
expect(await drop(page, "Item a", "b")).toEqual([{
|
||||||
|
type: "item", sourceId: "a", targetId: "b", targetType: "item", placement: "after",
|
||||||
|
sourceGroup: "A", targetGroup: "A", sourceParentId: "p", targetParentId: "p"
|
||||||
|
}]);
|
||||||
|
expect(await drop(page, "Item a", "d")).toEqual([]);
|
||||||
|
expect(await drop(page, "Item a", "alien")).toEqual([]);
|
||||||
|
await configure(page, { reorderFeatures: { item: { groupChange: false } } });
|
||||||
|
expect(await drop(page, "Item a", "c")).toEqual([]);
|
||||||
|
await configure(page, { reorderFeatures: { item: { groupChange: true } } });
|
||||||
|
expect(await drop(page, "Item a", "c")).toMatchObject([{ targetId: "c", targetGroup: "B" }]);
|
||||||
|
await configure(page, { hierarchy: { enabled: true, stickyParents: false } });
|
||||||
|
expect(await drop(page, "Item a", "d")).toMatchObject([{ sourceParentId: "p", targetParentId: "q" }]);
|
||||||
|
await configure(page, { veto: true });
|
||||||
|
expect(await drop(page, "Item a", "b")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("orientation changes placement while explicit boundaries and ungrouped drops require permission", async ({ page }) => {
|
||||||
|
expect(await drop(page, "Item a", "b")).toMatchObject([{ placement: "after" }]);
|
||||||
|
await configure(page, { orientation: "horizontal" });
|
||||||
|
expect(await drop(page, "Item a", "b")).toMatchObject([{ placement: "before" }]);
|
||||||
|
expect(await drop(page, "Item a", "b", 0.8, 0.2)).toMatchObject([{ placement: "after" }]);
|
||||||
|
expect(await drop(page, "Item a", "boundary")).toEqual([]);
|
||||||
|
expect(await drop(page, "Item a", "ungrouped")).toEqual([]);
|
||||||
|
await configure(page, { reorderFeatures: { item: { boundaryDrop: true, ungroupedDrop: true } } });
|
||||||
|
expect(await drop(page, "Item a", "boundary", 0.1, 0.1)).toMatchObject([{ targetType: "boundary", targetGroup: "B", placement: "after" }]);
|
||||||
|
expect(await drop(page, "Item a", "ungrouped")).toMatchObject([{ targetType: "ungrouped", targetGroup: "" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("group dragging rejects itself, disabled and root-only moves and respects custom vetoes", async ({ page }) => {
|
||||||
|
expect(await drop(page, "Group A", "group-A")).toEqual([]);
|
||||||
|
expect(await drop(page, "Group A", "group-B")).toEqual([{
|
||||||
|
type: "group", sourceId: "A", targetId: "B", targetType: "group", placement: "after",
|
||||||
|
sourceGroup: "A", targetGroup: "B", sourceParentId: "p", targetParentId: "p"
|
||||||
|
}]);
|
||||||
|
await configure(page, { reorderFeatures: { group: { reorder: false } } });
|
||||||
|
expect(await drop(page, "Group A", "group-B")).toEqual([]);
|
||||||
|
await configure(page, { reorderFeatures: { group: { reorder: true, rootOnly: true } } });
|
||||||
|
expect(await drop(page, "Group A", "group-B")).toEqual([]);
|
||||||
|
await configure(page, { reorderFeatures: { group: { reorder: true } }, veto: true });
|
||||||
|
expect(await drop(page, "Group A", "group-B")).toEqual([]);
|
||||||
|
});
|
||||||
416
e2e/guide-checklist.spec.js
Normal file
416
e2e/guide-checklist.spec.js
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
// Rôle : vérifie copie, liaison persistante et progression des checklists dans les guides.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
import { importChecklistText } from "../website/src/features/toolboxes/modules/textImport/basic.js";
|
||||||
|
import { normalizeChecklistData } from "../website/src/features/toolboxes/storage/modules/checklist.js";
|
||||||
|
|
||||||
|
const route = "/#/games/aniimo/aniimos";
|
||||||
|
const storageKey = "sokkog:guide-checklists";
|
||||||
|
const controls = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).locator(".guide-checklist-controls");
|
||||||
|
const controlsToggle = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).getByRole("button", { name: "Configuration", exact: true });
|
||||||
|
const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link");
|
||||||
|
const activeButtons = (page) => page.locator('.guide-checklist-link[aria-pressed="true"]');
|
||||||
|
const hiddenSwitch = (page) => page.locator('.guide-checklist-controls[data-linked="true"]').getByRole("button", { name: "Masquer les cochés", exact: true });
|
||||||
|
const json = (path) => JSON.parse(readFileSync(`website/public/data/${path}`, "utf8"));
|
||||||
|
const row = (label, qtyCurrent = 0) => ({ id: label, label, qtyCurrent });
|
||||||
|
|
||||||
|
async function seed(page, gameId = "aniimo", lists = {
|
||||||
|
capture: [row("Loufeuteau")],
|
||||||
|
perfect: [row("Loufeuteau", 1)],
|
||||||
|
forms: [row("Loufeuteau — Forme de base", 1), row("Emberpup — Mountain Woods Form")],
|
||||||
|
unrelated: [row("Unrelated")]
|
||||||
|
}) {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.evaluate(async ({ gameId, lists }) => {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const request = globalThis.indexedDB.open("sokkog", 1);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const tx = db.transaction(["kv", "modules"], "readwrite");
|
||||||
|
tx.objectStore("kv").put([
|
||||||
|
{ id: "guide", name: "Guide toolbox", modules: Object.keys(lists).map((id) => ({ id, type: "checklist", title: id })), moduleColumns: 1 },
|
||||||
|
{ id: "other", name: "Other toolbox", modules: [{ id: "outside", type: "checklist", title: "Outside" }] }
|
||||||
|
], "toolboxes");
|
||||||
|
tx.objectStore("kv").put({ [gameId]: "guide" }, "links");
|
||||||
|
for (const [id, items] of Object.entries(lists)) tx.objectStore("modules").put({ key: `guide:${id}`, data: { items } });
|
||||||
|
tx.objectStore("modules").put({ key: "other:outside", data: { items: [{ id: "outside", label: "Loufeuteau", qtyCurrent: 1 }] } });
|
||||||
|
tx.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}, { gameId, lists });
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readModule(page, moduleId) {
|
||||||
|
return page.evaluate(async (moduleId) => new Promise((resolve, reject) => {
|
||||||
|
const request = globalThis.indexedDB.open("sokkog", 1);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const tx = db.transaction("modules", "readonly");
|
||||||
|
const get = tx.objectStore("modules").get(`guide:${moduleId}`);
|
||||||
|
get.onsuccess = () => resolve(get.result.data);
|
||||||
|
tx.oncomplete = () => db.close();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
};
|
||||||
|
}), moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCreatedChecklist(page, title) {
|
||||||
|
return page.evaluate(async (title) => new Promise((resolve, reject) => {
|
||||||
|
const request = globalThis.indexedDB.open("sokkog", 1);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const tx = db.transaction(["kv", "modules"], "readonly");
|
||||||
|
const toolboxesRequest = tx.objectStore("kv").get("toolboxes");
|
||||||
|
const modulesRequest = tx.objectStore("modules").getAll();
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
const toolbox = toolboxesRequest.result.find((item) => item.id === "guide");
|
||||||
|
const module = toolbox.modules.find((item) => item.type === "checklist" && item.title === title);
|
||||||
|
const data = modulesRequest.result.find((entry) => entry.key === `guide:${module?.id}`)?.data;
|
||||||
|
const activeSpace = toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId);
|
||||||
|
db.close();
|
||||||
|
resolve({ module, data, activeModuleIds: activeSpace?.moduleOrder.one || [] });
|
||||||
|
};
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
}), title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyVisibleResults(page) {
|
||||||
|
await page.locator(".results-copy-button").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
|
||||||
|
await dialog.getByRole("button", { name: "Copier le format texte", exact: true }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDrawer(page) {
|
||||||
|
const drawer = page.locator("#toolbox-drawer");
|
||||||
|
if (!await drawer.locator(".drawer-panel").isVisible()) {
|
||||||
|
const compactTrigger = page.getByRole("tab", { name: "Toolbox", exact: true });
|
||||||
|
if (await compactTrigger.isVisible()) await compactTrigger.click();
|
||||||
|
else await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click();
|
||||||
|
}
|
||||||
|
await expect(drawer.locator(".drawer-panel")).toBeVisible();
|
||||||
|
return drawer;
|
||||||
|
}
|
||||||
|
async function closeDrawer(page) {
|
||||||
|
await page.locator("#toolbox-drawer").getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
}
|
||||||
|
async function openControls(page, id) {
|
||||||
|
const toggle = id ? controlsToggle(page, id) : page.locator('.module-configuration-button[data-guide-linked="true"]');
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click();
|
||||||
|
}
|
||||||
|
async function choose(page, id) {
|
||||||
|
await openDrawer(page);
|
||||||
|
await openControls(page, id);
|
||||||
|
await linkButton(page, id).click();
|
||||||
|
await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(activeButtons(page)).toHaveCount(1);
|
||||||
|
await closeDrawer(page);
|
||||||
|
}
|
||||||
|
async function toggleHidden(page) {
|
||||||
|
await openDrawer(page);
|
||||||
|
await openControls(page);
|
||||||
|
await hiddenSwitch(page).click();
|
||||||
|
await closeDrawer(page);
|
||||||
|
}
|
||||||
|
async function assertLink(page, id, hidden) {
|
||||||
|
await openDrawer(page);
|
||||||
|
if (id) await openControls(page, id);
|
||||||
|
await expect(activeButtons(page)).toHaveCount(id ? 1 : 0);
|
||||||
|
if (id) await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true");
|
||||||
|
if (id) await expect(hiddenSwitch(page)).toHaveAttribute("aria-pressed", String(hidden));
|
||||||
|
else await expect(hiddenSwitch(page)).toHaveCount(0);
|
||||||
|
await closeDrawer(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Aniimo copies visible species or forms and round-trips through checklist import", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await seed(page);
|
||||||
|
await page.goto(route);
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
|
||||||
|
const copy = page.locator(".results-copy-button");
|
||||||
|
await expect(copy).toHaveAccessibleName(/sans les variantes/);
|
||||||
|
await expect(copy).toContainText("Résultats vers checklist");
|
||||||
|
await expect(copy.locator(".ui-icon-checklist")).toBeVisible();
|
||||||
|
await expect(page.locator('[data-copy-target="true"]')).toHaveText("1 / 88 Aniimos");
|
||||||
|
await copyVisibleResults(page);
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1");
|
||||||
|
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
||||||
|
await expect(copy).toHaveAccessibleName(/toutes les variantes/);
|
||||||
|
await expect(page.locator('[data-copy-target="true"]')).toHaveText("3 / 208 variantes");
|
||||||
|
await copyVisibleResults(page);
|
||||||
|
const text = await page.evaluate(() => globalThis.navigator.clipboard.readText());
|
||||||
|
const imported = importChecklistText(text, { uid: (prefix) => `${prefix}-${Math.random()}`, normalizeChecklistData });
|
||||||
|
expect(imported.items.map((item) => item.label)).toEqual(["Loufeuteau — Forme de base", "Loufeuteau — Forme des montagnes boisées", "Loufeuteau — Hautes-plaines"]);
|
||||||
|
expect(imported.items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true);
|
||||||
|
await choose(page, "forms");
|
||||||
|
await toggleHidden(page);
|
||||||
|
await copyVisibleResults(page);
|
||||||
|
expect(await page.evaluate(() => globalThis.navigator.clipboard.readText())).not.toContain("Forme de base");
|
||||||
|
await choose(page, "perfect");
|
||||||
|
await expect(copy).toBeDisabled();
|
||||||
|
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filtered results explain unavailable creation when no toolbox is linked", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.goto(route);
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
await page.locator(".results-copy-button").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
|
||||||
|
await expect(dialog.getByText("Liez une toolbox à cette page pour créer directement la checklist.")).toBeVisible();
|
||||||
|
await expect(dialog.getByRole("button", { name: "Créer dans la toolbox", exact: true })).toBeDisabled();
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
|
||||||
|
await dialog.getByRole("button", { name: "Copier le format texte", exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filtered results create a checklist in the linked active space without replacing the guide association", async ({ page }) => {
|
||||||
|
await seed(page);
|
||||||
|
await page.goto(route);
|
||||||
|
await choose(page, "forms");
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
const associationBefore = await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey);
|
||||||
|
await page.locator(".results-copy-button").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
|
||||||
|
await expect(dialog.getByText("Guide toolbox", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Aniimos", { exact: true })).toBeVisible();
|
||||||
|
await dialog.getByRole("button", { name: "Créer dans la toolbox", exact: true }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Checklist créée dans la toolbox associée.")).toBeVisible();
|
||||||
|
|
||||||
|
await expect.poll(async () => (await readCreatedChecklist(page, "Aniimos")).module?.id).toBeTruthy();
|
||||||
|
const created = await readCreatedChecklist(page, "Aniimos");
|
||||||
|
expect(created.activeModuleIds).toContain(created.module.id);
|
||||||
|
expect(normalizeChecklistData(created.data).items.map((item) => item.label)).toEqual(["Loufeuteau"]);
|
||||||
|
expect(normalizeChecklistData(created.data).items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true);
|
||||||
|
expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBe(associationBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [1280, 768]) {
|
||||||
|
test(`drawer linking, page controls and persistent restoration at ${width}px`, async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await seed(page);
|
||||||
|
await page.goto(route);
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
const card = page.locator(".aniimo-result");
|
||||||
|
const pageControls = page.locator(".guide-checklist-page-controls");
|
||||||
|
const unlinkedSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
|
||||||
|
await expect(pageControls).toBeVisible();
|
||||||
|
await expect(unlinkedSelect).toHaveValue("");
|
||||||
|
await expect(unlinkedSelect).toContainText("Choisir une checklist");
|
||||||
|
await expect(pageControls.getByRole("button")).toHaveCount(0);
|
||||||
|
await openDrawer(page);
|
||||||
|
await expect(activeButtons(page)).toHaveCount(0);
|
||||||
|
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".guide-checklist-controls select")).toHaveCount(0);
|
||||||
|
await expect(controls(page, "unrelated")).toHaveCount(0);
|
||||||
|
await expect(controlsToggle(page, "unrelated")).toHaveCount(1);
|
||||||
|
await expect(controls(page, "capture")).toHaveCount(0);
|
||||||
|
await openControls(page, "capture");
|
||||||
|
await expect(controls(page, "capture").getByRole("button", { name: "Masquer les cochés" })).toBeDisabled();
|
||||||
|
const before = await readModule(page, "forms");
|
||||||
|
const heading = controlsToggle(page, "forms");
|
||||||
|
await expect(heading).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(await heading.evaluate((button) => button.previousElementSibling.classList.contains("module-edit-button"))).toBe(true);
|
||||||
|
await heading.press("Enter");
|
||||||
|
await expect(heading).toHaveAttribute("aria-expanded", "true");
|
||||||
|
await expect(page.locator(`[id="${await heading.getAttribute("aria-controls")}"]`)).toHaveClass(/module-configuration-panel/);
|
||||||
|
await linkButton(page, "forms").press("Enter");
|
||||||
|
await expect(linkButton(page, "forms")).toHaveAttribute("aria-pressed", "true");
|
||||||
|
const panel = controls(page, "forms");
|
||||||
|
await heading.press("Space");
|
||||||
|
await expect(heading).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await expect(heading).toHaveAttribute("data-guide-linked", "true");
|
||||||
|
await expect(linkButton(page, "forms")).toBeHidden();
|
||||||
|
await expect(card).toHaveAttribute("data-checklist-complete", "true");
|
||||||
|
await heading.press("Enter");
|
||||||
|
await expect(linkButton(page, "forms")).toBeVisible();
|
||||||
|
const bounds = await panel.boundingBox();
|
||||||
|
const actionsBounds = await panel.locator(".guide-checklist-actions").boundingBox();
|
||||||
|
expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
|
||||||
|
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
|
||||||
|
await closeDrawer(page);
|
||||||
|
await expect(pageControls).toBeVisible();
|
||||||
|
const pageControlsBounds = await pageControls.boundingBox();
|
||||||
|
const pageHeadingBounds = await page.locator(".page-heading").boundingBox();
|
||||||
|
const headingActionsBounds = await page.locator(".game-heading-actions").boundingBox();
|
||||||
|
expect(pageControlsBounds.y).toBeGreaterThanOrEqual(pageHeadingBounds.y);
|
||||||
|
expect(pageControlsBounds.y + pageControlsBounds.height).toBeLessThanOrEqual(pageHeadingBounds.y + pageHeadingBounds.height);
|
||||||
|
if (width > 1024) {
|
||||||
|
expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0);
|
||||||
|
expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0);
|
||||||
|
}
|
||||||
|
const checklistSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
|
||||||
|
await expect(checklistSelect).toHaveValue("forms");
|
||||||
|
await expect(checklistSelect.locator("option")).toHaveCount(3);
|
||||||
|
await expect(checklistSelect.locator('option[value="unrelated"]')).toHaveCount(0);
|
||||||
|
await checklistSelect.selectOption("capture");
|
||||||
|
await expect(checklistSelect).toHaveValue("capture");
|
||||||
|
await checklistSelect.selectOption("forms");
|
||||||
|
await expect(checklistSelect).toHaveValue("forms");
|
||||||
|
await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible();
|
||||||
|
await expect(card).toHaveAttribute("data-checklist-complete", "true");
|
||||||
|
await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0);
|
||||||
|
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
|
||||||
|
await expect(card.locator(".aniimo-back .guide-checklist-mark")).toHaveAccessibleName("Coché");
|
||||||
|
await card.getByRole("button", { name: /Afficher le portrait/ }).click();
|
||||||
|
const cardCheckbox = card.getByRole("checkbox", { name: "Décocher dans la checklist" });
|
||||||
|
await expect(cardCheckbox).toBeChecked();
|
||||||
|
await cardCheckbox.uncheck();
|
||||||
|
await expect(card).not.toHaveAttribute("data-checklist-complete", "true");
|
||||||
|
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "true");
|
||||||
|
await expect.poll(async () => (await readModule(page, "forms")).items.find((item) => item.label.includes("Forme de base")).qtyCurrent || 0).toBe(0);
|
||||||
|
await card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
|
||||||
|
await expect(card).toHaveAttribute("data-checklist-complete", "true");
|
||||||
|
await pageControls.getByRole("button", { name: "Masquer les cochés", exact: true }).click();
|
||||||
|
await expect(card.getByRole("tab")).toHaveCount(2);
|
||||||
|
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
|
||||||
|
expect(normalizeChecklistData(await readModule(page, "forms")).items).toEqual(normalizeChecklistData(before).items);
|
||||||
|
await choose(page, "capture");
|
||||||
|
await assertLink(page, "capture", true);
|
||||||
|
await expect(card.getByRole("tab")).toHaveCount(3);
|
||||||
|
const drawer = await openDrawer(page);
|
||||||
|
await drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }).getByRole("checkbox", { name: "Terminer Loufeuteau", exact: true }).check();
|
||||||
|
await closeDrawer(page);
|
||||||
|
await expect(card).toHaveCount(0);
|
||||||
|
await expect.poll(() => page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).not.toBeNull();
|
||||||
|
await page.evaluate(() => globalThis.sessionStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await assertLink(page, "capture", true);
|
||||||
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
|
await expect(card).toHaveCount(0);
|
||||||
|
await openDrawer(page);
|
||||||
|
await openControls(page, "capture");
|
||||||
|
await hiddenSwitch(page).press("Space");
|
||||||
|
await closeDrawer(page);
|
||||||
|
await expect(card).toHaveAttribute("data-checklist-complete", "true");
|
||||||
|
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
|
||||||
|
await page.locator(".guide-checklist-page-controls").getByRole("button", { name: "Dissocier la checklist", exact: true }).click();
|
||||||
|
await openDrawer(page);
|
||||||
|
await openControls(page, "capture");
|
||||||
|
await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(activeButtons(page)).toHaveCount(0);
|
||||||
|
await expect(hiddenSwitch(page)).toHaveCount(0);
|
||||||
|
await closeDrawer(page);
|
||||||
|
await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0);
|
||||||
|
expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull();
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [gameId, category, cardClass, dataPath, dataKey, checklistTitle] of [
|
||||||
|
["mhwilds", "monsters", ".monster-card", "mhwilds/monsters.json", "monsters", "Monstres"],
|
||||||
|
["mhwilds", "endemic", ".endemic-card", "mhwilds/endemic_life.json", "endemicLife", "Faune endémique"],
|
||||||
|
["diablo4", "affixes", ".diablo4-affix-card", "diablo4/affixes_types.json", "affixes", "Affixes"]
|
||||||
|
]) {
|
||||||
|
test(`${gameId}/${category} links from its drawer and hides completed cards`, async ({ page }) => {
|
||||||
|
const item = json(dataPath)[dataKey][0];
|
||||||
|
const label = gameId === "mhwilds" ? json("mhwilds/i18n/fr.json")[item.name] : item.label;
|
||||||
|
await seed(page, gameId, { progress: [row(label, 1)] });
|
||||||
|
await page.goto(`/#/games/${gameId}/${category}`);
|
||||||
|
await expect(page.locator(cardClass).first()).toBeVisible();
|
||||||
|
const resultsAction = page.locator(".results-copy-button");
|
||||||
|
await expect(resultsAction).toContainText("Résultats vers checklist");
|
||||||
|
await expect(resultsAction.locator(".ui-icon-checklist")).toBeVisible();
|
||||||
|
await resultsAction.click();
|
||||||
|
const resultsDialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
|
||||||
|
await expect(resultsDialog.getByText(checklistTitle, { exact: true })).toBeVisible();
|
||||||
|
await resultsDialog.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
const count = await page.locator(cardClass).count();
|
||||||
|
await choose(page, "progress");
|
||||||
|
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);
|
||||||
|
const completedCard = page.locator(`${cardClass}[data-checklist-complete="true"]`);
|
||||||
|
const cardCheckbox = completedCard.getByRole("checkbox", { name: "Décocher dans la checklist" });
|
||||||
|
await expect(cardCheckbox).toBeChecked();
|
||||||
|
await cardCheckbox.uncheck();
|
||||||
|
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(0);
|
||||||
|
await page.locator(cardClass).first().getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
|
||||||
|
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);
|
||||||
|
await toggleHidden(page);
|
||||||
|
await expect(page.locator(cardClass)).toHaveCount(count - 1);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.reload();
|
||||||
|
await assertLink(page, "progress", true);
|
||||||
|
await expect(page.locator(cardClass)).toHaveCount(count - 1);
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renames retain a zero-match selection; changing toolbox clears the association", async ({ page }) => {
|
||||||
|
await seed(page);
|
||||||
|
await page.goto(route);
|
||||||
|
await choose(page, "capture");
|
||||||
|
const drawer = await openDrawer(page);
|
||||||
|
const checklist = drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) });
|
||||||
|
await openControls(page, "capture");
|
||||||
|
await checklist.getByRole("button", { name: "Modifier Loufeuteau", exact: true }).click();
|
||||||
|
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).fill("Unrelated now");
|
||||||
|
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).press("Enter");
|
||||||
|
await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.getByText("Cette checklist ne contient plus de correspondance avec le guide.")).toBeVisible();
|
||||||
|
await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Lier une toolbox" });
|
||||||
|
await dialog.getByRole("combobox").selectOption("other");
|
||||||
|
await dialog.getByRole("button", { name: "Valider", exact: true }).click();
|
||||||
|
await expect(activeButtons(page)).toHaveCount(0);
|
||||||
|
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
|
||||||
|
expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleting a selected checklist clears its link immediately", async ({ page }) => {
|
||||||
|
await seed(page);
|
||||||
|
await page.goto(route);
|
||||||
|
await choose(page, "capture");
|
||||||
|
await toggleHidden(page);
|
||||||
|
const drawer = await openDrawer(page);
|
||||||
|
await drawer.getByRole("button", { name: "Retirer capture", exact: true }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click();
|
||||||
|
await expect(activeButtons(page)).toHaveCount(0);
|
||||||
|
await expect(hiddenSwitch(page)).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await assertLink(page, "", false);
|
||||||
|
expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unavailable local storage retains links in memory across navigation; overview has no guide controls", async ({ page }) => {
|
||||||
|
await seed(page);
|
||||||
|
await page.addInitScript((key) => {
|
||||||
|
for (const method of ["getItem", "setItem", "removeItem"]) {
|
||||||
|
const original = globalThis.Storage.prototype[method];
|
||||||
|
globalThis.Storage.prototype[method] = function (name, ...args) {
|
||||||
|
if (name === key) throw new Error("Local storage unavailable");
|
||||||
|
return original.call(this, name, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, storageKey);
|
||||||
|
await page.reload();
|
||||||
|
await page.goto(route);
|
||||||
|
await choose(page, "perfect");
|
||||||
|
await toggleHidden(page);
|
||||||
|
await page.goto("/#/games/aniimo");
|
||||||
|
await openDrawer(page);
|
||||||
|
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
|
||||||
|
await closeDrawer(page);
|
||||||
|
await page.goto("/#/toolbox/guide");
|
||||||
|
await expect(page.locator('[data-module-type="checklist"]')).toHaveCount(4);
|
||||||
|
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
|
||||||
|
await expect(page.locator('.module-configuration-button[data-guide-linked="true"]')).toHaveCount(0);
|
||||||
|
await page.goto(route);
|
||||||
|
await assertLink(page, "perfect", true);
|
||||||
|
await openDrawer(page);
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(page.locator("#toolbox-drawer .drawer-panel")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".app-shell")).not.toHaveClass(/toolbox-drawer-open/);
|
||||||
|
});
|
||||||
30
e2e/helpers/storage.js
Normal file
30
e2e/helpers/storage.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Rôle : isole les scénarios Playwright en vidant le stockage local Sokko G.
|
||||||
|
/* global window */
|
||||||
|
export async function resetSokkoStorage(page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const request = window.indexedDB.open("sokkog", 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains("kv")) db.createObjectStore("kv");
|
||||||
|
if (!db.objectStoreNames.contains("modules")) db.createObjectStore("modules", { keyPath: "key" });
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const transaction = db.transaction(["kv", "modules"], "readwrite");
|
||||||
|
transaction.objectStore("kv").clear();
|
||||||
|
transaction.objectStore("modules").clear();
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
transaction.onabort = () => reject(transaction.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
36
e2e/home.spec.js
Normal file
36
e2e/home.spec.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Rôle : vérifie en navigateur que la home charge les zones principales.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => resetSokkoStorage(page));
|
||||||
|
|
||||||
|
test("home page renders the application shell", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.locator(".app-shell")).toBeVisible();
|
||||||
|
const main = page.getByRole("main");
|
||||||
|
const hero = main.locator(".hero");
|
||||||
|
await expect(main).toBeVisible();
|
||||||
|
await expect(main.getByRole("heading", { name: "Sokko G", exact: true })).toBeVisible();
|
||||||
|
const create = hero.getByRole("button", { name: "Créer ma première toolbox", exact: true });
|
||||||
|
await expect(create).toBeVisible();
|
||||||
|
await expect(main.getByRole("link", { name: "Voir les jeux", exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator(".home-stats")).toBeVisible();
|
||||||
|
await expect(page.locator(".origin-section")).toBeVisible();
|
||||||
|
|
||||||
|
await create.click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Nouvelle toolbox", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("home links to the toolbox list once a toolbox exists", async ({ page }) => {
|
||||||
|
await page.locator(".hero").getByRole("button", { name: "Créer ma première toolbox", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Nouvelle toolbox", exact: true });
|
||||||
|
await dialog.getByRole("textbox", { name: "Nom", exact: true }).fill("Accueil contextualisé");
|
||||||
|
await dialog.getByRole("button", { name: "Créer", exact: true }).click();
|
||||||
|
await page.goto("/#/");
|
||||||
|
|
||||||
|
const link = page.getByRole("main").getByRole("link", { name: "Voir mes toolboxes", exact: true });
|
||||||
|
await expect(link).toBeVisible();
|
||||||
|
await link.click();
|
||||||
|
await expect(page).toHaveURL(/#\/toolboxes$/);
|
||||||
|
});
|
||||||
51
e2e/inline-edit.spec.js
Normal file
51
e2e/inline-edit.spec.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Rôle : teste la synchronisation, la validation et l'annulation des éditions inline.
|
||||||
|
/* global window, document */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/inline-edit-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
for (const contentEditable of [false, true]) {
|
||||||
|
test(`inline ${contentEditable ? "contentEditable" : "input"} synchronizes and cancels under StrictMode`, async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async ({ probeUrl, contentEditable }) => {
|
||||||
|
const { mountInlineEditProbe } = await import(probeUrl);
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.id = "inline-probe";
|
||||||
|
document.body.prepend(element);
|
||||||
|
window.inlineProbe = mountInlineEditProbe(element, contentEditable);
|
||||||
|
}, { probeUrl, contentEditable });
|
||||||
|
const editor = page.locator("#inline-probe").getByRole("textbox", { name: "Edit" });
|
||||||
|
const expectValue = (value) => contentEditable ? expect(editor).toHaveText(value) : expect(editor).toHaveValue(value);
|
||||||
|
await expectValue("Initial");
|
||||||
|
await editor.fill("Uncommitted");
|
||||||
|
await page.evaluate(() => window.inlineProbe.update("Initial"));
|
||||||
|
await expectValue("Uncommitted");
|
||||||
|
await editor.press("Escape");
|
||||||
|
await expectValue("Initial");
|
||||||
|
expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual([]);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.inlineProbe.update("Updated"));
|
||||||
|
await expectValue("Updated");
|
||||||
|
const sourceRenders = await page.evaluate(() => window.inlineProbe.renders.filter((entry) => entry.value === "Updated"));
|
||||||
|
expect(sourceRenders.length).toBeGreaterThan(0);
|
||||||
|
expect(sourceRenders.every((entry) => entry.draft === "Updated")).toBe(true);
|
||||||
|
|
||||||
|
await editor.fill(" Saved ");
|
||||||
|
await editor.press("Enter");
|
||||||
|
expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual(["Saved"]);
|
||||||
|
await page.evaluate(() => window.inlineProbe.update("Saved"));
|
||||||
|
await expectValue("Saved");
|
||||||
|
await editor.fill("Discard");
|
||||||
|
await editor.press("Escape");
|
||||||
|
await expectValue("Saved");
|
||||||
|
expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual(["Saved"]);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.inlineProbe.update("Initial"));
|
||||||
|
await expectValue("Initial");
|
||||||
|
await page.evaluate(() => window.inlineProbe.unmount());
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
76
e2e/library.spec.js
Normal file
76
e2e/library.spec.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// Rôle : vérifie le chargement, la navigation par ancres et le nettoyage des défilements de la librairie.
|
||||||
|
/* global window, document */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { LIBRARY_CATEGORY_DEFINITIONS } from "../website/src/features/toolboxes/toolboxModuleRegistry.js";
|
||||||
|
|
||||||
|
test("loads every library tool through the shared component registry", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text());
|
||||||
|
});
|
||||||
|
await page.goto("/#/library");
|
||||||
|
for (const category of LIBRARY_CATEGORY_DEFINITIONS) {
|
||||||
|
await page.locator(`.library-doc-nav-group > a[href="#/library#library-category-${category.key}"]`).click();
|
||||||
|
for (const type of category.types) {
|
||||||
|
const tools = page.locator(`[data-module-type="${type}"] .module-content`);
|
||||||
|
await expect(tools.first()).toBeVisible();
|
||||||
|
for (const tool of await tools.all()) {
|
||||||
|
await expect(tool.getByText("Chargement de l'outil...")).toHaveCount(0);
|
||||||
|
await expect(tool.locator(":scope > *").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("library follows deep links, same-page hashes and browser history", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/#/library#library-tool-m6");
|
||||||
|
await expect(page.locator('[data-module-type="combos"]')).toBeVisible();
|
||||||
|
await expect(page.locator("#library-category-buildsCommands")).toBeVisible();
|
||||||
|
await page.evaluate(() => { window.location.hash = "/library#library-tool-m8"; });
|
||||||
|
await expect(page.locator('[data-module-type="table"]')).toBeVisible();
|
||||||
|
await expect(page.locator("#library-category-calculationData")).toBeVisible();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.locator('[data-module-type="combos"]')).toBeVisible();
|
||||||
|
await page.evaluate(() => { window.location.hash = "/library#%E0%A4%A"; });
|
||||||
|
await expect(page.locator("#library-category-notesTracking")).toBeVisible();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("library cancels obsolete scroll retries on selection and unmount", async ({ page }) => {
|
||||||
|
await page.goto("/#/library");
|
||||||
|
await expect(page.locator('[data-module-type="notepad"]')).toBeVisible();
|
||||||
|
await page.clock.install({ time: new Date("2026-09-11T12:00:00Z") });
|
||||||
|
await page.clock.pauseAt(new Date("2026-09-11T12:00:01Z"));
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.libraryScrolls = [];
|
||||||
|
window.scrollTo = (options) => window.libraryScrolls.push(options);
|
||||||
|
});
|
||||||
|
const nav = page.locator(".library-doc-nav");
|
||||||
|
await nav.locator('a[href="#/library#library-tool-m0"]').dispatchEvent("click");
|
||||||
|
await page.clock.runFor(40);
|
||||||
|
expect(await page.evaluate(() => window.libraryScrolls.length)).toBeGreaterThan(0);
|
||||||
|
await nav.locator('a[href="#/library#library-tool-m1"]').dispatchEvent("click");
|
||||||
|
await page.evaluate(() => { window.libraryScrolls = []; });
|
||||||
|
await page.clock.runFor(700);
|
||||||
|
const scrolls = await page.evaluate(() => window.libraryScrolls);
|
||||||
|
expect(scrolls).toHaveLength(4);
|
||||||
|
expect(scrolls.map((scroll) => scroll.behavior)).toEqual(["smooth", "auto", "auto", "auto"]);
|
||||||
|
|
||||||
|
await nav.locator('a[href="#/library#library-tool-m2"]').dispatchEvent("click");
|
||||||
|
await page.clock.runFor(40);
|
||||||
|
await page.evaluate(() => { window.location.hash = "/"; });
|
||||||
|
await expect(page.locator(".library-page")).toHaveCount(0);
|
||||||
|
// Garder une cible homonyme permet de détecter un ancien timer après démontage.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const target = document.createElement("div");
|
||||||
|
target.id = "library-tool-m2";
|
||||||
|
document.body.append(target);
|
||||||
|
window.libraryScrolls = [];
|
||||||
|
});
|
||||||
|
await page.clock.runFor(700);
|
||||||
|
expect(await page.evaluate(() => window.libraryScrolls)).toEqual([]);
|
||||||
|
});
|
||||||
114
e2e/memos-ticker.spec.js
Normal file
114
e2e/memos-ticker.spec.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// Rôle : vérifie la bande de mémos fixe sur les pages de toolbox et de jeu associées.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
async function seedMemosToolbox(page) {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const request = globalThis.indexedDB.open("sokkog", 1);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const tx = db.transaction(["kv", "modules"], "readwrite");
|
||||||
|
const soonReset = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
const resetTime = `${String(soonReset.getHours()).padStart(2, "0")}:${String(soonReset.getMinutes()).padStart(2, "0")}`;
|
||||||
|
tx.objectStore("kv").put([
|
||||||
|
{
|
||||||
|
id: "memo-toolbox",
|
||||||
|
name: "Toolbox mémos",
|
||||||
|
modules: [
|
||||||
|
{ id: "memos", type: "memos", title: "Mémos" },
|
||||||
|
{ id: "planner-one", type: "taskPlanner", title: "Urgent planner" },
|
||||||
|
{ id: "planner-two", type: "taskPlanner", title: "Other planner" }
|
||||||
|
],
|
||||||
|
moduleColumns: 1
|
||||||
|
}
|
||||||
|
], "toolboxes");
|
||||||
|
tx.objectStore("kv").put({ mhwilds: "memo-toolbox" }, "links");
|
||||||
|
tx.objectStore("modules").put({
|
||||||
|
key: "memo-toolbox:memos",
|
||||||
|
data: { items: [{ id: "memo-1", text: "Préparer les potions", color: "#f6c453" }] }
|
||||||
|
});
|
||||||
|
tx.objectStore("modules").put({
|
||||||
|
key: "memo-toolbox:planner-one",
|
||||||
|
data: {
|
||||||
|
resetTime,
|
||||||
|
tasks: [
|
||||||
|
{ id: "category-parent", title: "Raid principal", type: "unique", category: "Raid" },
|
||||||
|
{ id: "daily-task", title: "Faire le donjon", type: "daily" },
|
||||||
|
{ id: "done-task", title: "Déjà terminée", type: "daily", checked: true, checkedAt: Date.now() }
|
||||||
|
],
|
||||||
|
relations: [{ id: "relation", fromTaskId: "daily-task", toTaskId: "category-parent" }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx.objectStore("modules").put({
|
||||||
|
key: "memo-toolbox:planner-two",
|
||||||
|
data: { tasks: [{ id: "unique-task", title: "Ponctuelle", type: "unique" }] }
|
||||||
|
});
|
||||||
|
tx.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shows and persists the memo ticker on linked game and full toolbox pages", async ({ page }) => {
|
||||||
|
await seedMemosToolbox(page);
|
||||||
|
|
||||||
|
await page.goto("/#/games/mhwilds/monsters");
|
||||||
|
const globalTicker = page.locator(".global-memos-ticker");
|
||||||
|
await expect(globalTicker).toBeVisible();
|
||||||
|
await expect(globalTicker).toContainText("Préparer les potions");
|
||||||
|
await expect(globalTicker).toHaveCSS("position", "fixed");
|
||||||
|
|
||||||
|
await page.goto("/#/games/aniimo/aniimos");
|
||||||
|
await expect(globalTicker).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto("/#/toolbox/memo-toolbox");
|
||||||
|
await expect(globalTicker).toBeVisible();
|
||||||
|
const toggle = page.locator(".memos-ticker-toggle");
|
||||||
|
await expect(toggle).toHaveAccessibleName("Masquer la bande");
|
||||||
|
await toggle.click();
|
||||||
|
await expect(globalTicker).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(globalTicker).toHaveCount(0);
|
||||||
|
await expect(toggle).toHaveAccessibleName("Afficher la bande");
|
||||||
|
await toggle.click();
|
||||||
|
await expect(globalTicker).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(globalTicker).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("links a planner, shows its reset warning and clears the link when it is removed", async ({ page }) => {
|
||||||
|
await seedMemosToolbox(page);
|
||||||
|
await page.goto("/#/toolbox/memo-toolbox");
|
||||||
|
|
||||||
|
const memos = page.locator('[data-module-id="memos"]');
|
||||||
|
await memos.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
const dataSectionBox = await memos.locator(".module-configuration-data").boundingBox();
|
||||||
|
const settingsSectionBox = await memos.locator(".module-configuration-specific").boundingBox();
|
||||||
|
expect(settingsSectionBox.y).toBeGreaterThan(dataSectionBox.y + dataSectionBox.height);
|
||||||
|
expect(settingsSectionBox.width).toBeCloseTo(dataSectionBox.width, 0);
|
||||||
|
const plannerSelect = memos.getByRole("combobox", { name: "Planificateur lié", exact: true });
|
||||||
|
await expect(plannerSelect).toHaveValue("");
|
||||||
|
await plannerSelect.selectOption("planner-one");
|
||||||
|
|
||||||
|
const globalTicker = page.locator(".global-memos-ticker");
|
||||||
|
await expect(globalTicker).toContainText("Tâche : Raid Faire le donjon expire bientôt");
|
||||||
|
await expect(globalTicker).not.toContainText("Déjà terminée");
|
||||||
|
await expect(globalTicker.locator(".memos-ticker-item.is-task-warning .ui-icon-warning")).toHaveCount(2);
|
||||||
|
|
||||||
|
const planner = page.locator('[data-module-id="planner-one"]');
|
||||||
|
await planner.getByRole("button", { name: "Retirer Urgent planner", exact: true }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click();
|
||||||
|
await expect(planner).toHaveCount(0);
|
||||||
|
await expect(plannerSelect).toHaveValue("");
|
||||||
|
await expect(globalTicker).not.toContainText("Faire le donjon");
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await memos.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
await expect(plannerSelect).toHaveValue("");
|
||||||
|
});
|
||||||
79
e2e/module-order.spec.js
Normal file
79
e2e/module-order.spec.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// Rôle : vérifie les ordres indépendants une/deux colonnes et leur persistance après déplacement réel.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
async function setup(page, columns) {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.evaluate(async (columns) => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
await db.setToolboxSnapshot([{
|
||||||
|
id: "order", name: "Ordering", moduleColumns: columns,
|
||||||
|
modules: ["a", "b", "c", "d"].map((id) => ({ id, type: "counters", title: id.toUpperCase() })),
|
||||||
|
moduleOrder: { one: ["a", "b", "c", "d"], two: [["a", "b"], ["c", "d"]] }
|
||||||
|
}], {}, {});
|
||||||
|
}, columns);
|
||||||
|
await page.goto("/#/toolbox/order");
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator("article.module")).toHaveCount(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savedOrder(page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
return (await db.getToolboxes())[0].spaces[0].moduleOrder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drag(page, title, target, after = false) {
|
||||||
|
await page.getByRole("button", { name: `Déplacer ${title}`, exact: true }).hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator("article.module.is-dragging")).toHaveCount(1);
|
||||||
|
await target.scrollIntoViewIfNeeded();
|
||||||
|
const box = await target.boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height * (after ? 0.8 : 0.2), { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator("article.module.is-dragging")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("one-column ordering survives reload without changing the two-column order", async ({ page }) => {
|
||||||
|
await setup(page, 1);
|
||||||
|
await drag(page, "C", page.locator('article[data-reorder-item-id="a"]'));
|
||||||
|
const expected = { one: ["c", "a", "b", "d"], two: [["a", "b"], ["c", "d"]] };
|
||||||
|
await expect.poll(() => savedOrder(page)).toEqual(expected);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator(".module-title")).toHaveText(["C", "A", "B", "D"]);
|
||||||
|
await page.getByRole("button", { name: "Afficher en colonnes", exact: true }).click();
|
||||||
|
await expect(page.locator(".module-column").first().locator(".module-title")).toHaveText(["A", "B"]);
|
||||||
|
await expect(page.locator(".module-column").last().locator(".module-title")).toHaveText(["C", "D"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cross-column drops and empty column boundaries preserve every module and the one-column order", async ({ page }) => {
|
||||||
|
await setup(page, 2);
|
||||||
|
const columns = page.locator(".module-column");
|
||||||
|
await drag(page, "A", page.locator('article[data-reorder-item-id="d"]'), true);
|
||||||
|
await expect.poll(() => savedOrder(page)).toEqual({ one: ["a", "b", "c", "d"], two: [["b"], ["c", "d", "a"]] });
|
||||||
|
await drag(page, "B", columns.last().locator(".module-column-boundary-drop-zone").last(), true);
|
||||||
|
await expect.poll(() => savedOrder(page)).toEqual({ one: ["a", "b", "c", "d"], two: [[], ["c", "d", "a", "b"]] });
|
||||||
|
await drag(page, "A", columns.first().locator(".module-column-boundary-drop-zone").first());
|
||||||
|
const expected = { one: ["a", "b", "c", "d"], two: [["a"], ["c", "d", "b"]] };
|
||||||
|
await expect.poll(() => savedOrder(page)).toEqual(expected);
|
||||||
|
await page.reload();
|
||||||
|
await expect(columns.first().locator(".module-title")).toHaveText(["A"]);
|
||||||
|
await expect(columns.last().locator(".module-title")).toHaveText(["C", "D", "B"]);
|
||||||
|
await page.getByRole("button", { name: "Afficher en lignes", exact: true }).click();
|
||||||
|
await expect(page.locator(".module-title")).toHaveText(["A", "B", "C", "D"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adding an off-screen tool scrolls it into view", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 900, height: 420 });
|
||||||
|
await setup(page, 1);
|
||||||
|
await page.getByRole("button", { name: "Afficher l'ajout rapide", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Ajouter Compteurs", exact: true }).click();
|
||||||
|
const added = page.locator('article[data-module-type="counters"]').last();
|
||||||
|
await expect(page.locator("article.module")).toHaveCount(5);
|
||||||
|
await expect.poll(async () => added.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.top >= 0 && rect.top < globalThis.window.innerHeight && rect.bottom > 0;
|
||||||
|
})).toBe(true);
|
||||||
|
expect(await page.evaluate(() => globalThis.window.scrollY)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
163
e2e/nds-controls.spec.js
Normal file
163
e2e/nds-controls.spec.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// Rôle : vérifie les tailles et états rendus des familles de contrôles NDS.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { mountStyles, computed, expectPseudo } from "./fixtures/compiled-styles.js";
|
||||||
|
|
||||||
|
test("native ranges support public sizes, keyboard thresholds and disabled states", async ({ page }) => {
|
||||||
|
for (const [size, height] of [["md", 44], ["sm", 36]]) {
|
||||||
|
await mountStyles(page, `<label style="display:block;padding:24px;width:260px">Niveau minimum<input class="nds-range" data-size="${size}" type="range" min="1" max="4" step="1" value="1"></label>`);
|
||||||
|
const range = page.getByRole("slider", { name: "Niveau minimum" });
|
||||||
|
await expect(range).toHaveCSS("height", `${height}px`);
|
||||||
|
await range.focus();
|
||||||
|
await range.press("End");
|
||||||
|
await expect(range).toHaveValue("4");
|
||||||
|
await range.press("ArrowLeft");
|
||||||
|
await expect(range).toHaveValue("3");
|
||||||
|
await expect(range).toHaveCSS("outline-style", "solid");
|
||||||
|
await range.evaluate((element) => { element.disabled = true; });
|
||||||
|
await expect(range).toBeDisabled();
|
||||||
|
await expect(range).toHaveCSS("cursor", "not-allowed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fields, selects and date triggers keep public sizes and keyboard focus", async ({ page }) => {
|
||||||
|
for (const [size, height, textareaHeight] of [["md", 44, 108], ["sm", 36, 88], ["xs", 32, 72]]) {
|
||||||
|
await mountStyles(page, `<div style="padding:24px;display:grid;gap:12px;width:300px">
|
||||||
|
<input class="nds-input" type="text" data-size="${size}" placeholder="Name">
|
||||||
|
<input class="nds-time" type="time" data-size="${size}">
|
||||||
|
<select class="nds-select" data-size="${size}"><option>Choice</option></select>
|
||||||
|
<textarea class="nds-textarea" data-size="${size}"></textarea>
|
||||||
|
<button class="nds-select-trigger" data-size="${size}">Choice</button>
|
||||||
|
<button class="nds-date-trigger" data-size="${size}">Date</button>
|
||||||
|
</div>`);
|
||||||
|
for (const field of await page.locator("input, select, textarea, button").all()) {
|
||||||
|
const isTextarea = await field.evaluate((element) => element.tagName === "TEXTAREA");
|
||||||
|
await expect(field).toHaveCSS("min-height", `${isTextarea ? textareaHeight : height}px`);
|
||||||
|
await field.focus();
|
||||||
|
await expect(field).toHaveCSS("border-top-color", "rgb(139, 92, 246)");
|
||||||
|
await expect(field).toHaveCSS("box-shadow", "rgba(139, 92, 246, 0.16) 0px 0px 0px 3px");
|
||||||
|
await expect(field).toHaveCSS("outline-style", "solid");
|
||||||
|
await field.evaluate((element) => { element.disabled = true; });
|
||||||
|
await expect(field).toHaveCSS("cursor", "not-allowed");
|
||||||
|
}
|
||||||
|
await expect(page.locator("textarea")).toHaveCSS("resize", "vertical");
|
||||||
|
await expect(page.locator("select")).toHaveCSS("appearance", "none");
|
||||||
|
await expect(page.locator("select")).toHaveCSS("background-image", /data:image\/svg/);
|
||||||
|
expect(await computed(page.locator(".nds-date-trigger"), "mask-image", "::after")).toContain("data:image/svg");
|
||||||
|
await expectPseudo(page.locator(".nds-date-trigger"), "::after", "pointer-events", "none");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text and icon buttons retain dimensions, variants, active and joined edges", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<div style="padding:24px;display:flex;flex-wrap:wrap;gap:12px">
|
||||||
|
${[["md", 40], ["sm", 34], ["xs", 30], ["xxs", 26], ["micro", 22]].map(([size]) =>
|
||||||
|
`<button class="nds-icon-button" data-size="${size}"><i class="ui-icon"></i></button>`).join("")}
|
||||||
|
${["md", "sm", "xs"].map((size) => `<button class="nds-button" data-size="${size}">${size}</button>`).join("")}
|
||||||
|
${["primary", "secondary", "ghost", "dashed", "danger"].map((variant) => `<button class="nds-button" data-variant="${variant}">${variant}</button>`).join("")}
|
||||||
|
<button id="edge" class="nds-icon-button" data-edge="left" data-border="joined">L</button>
|
||||||
|
</div>`);
|
||||||
|
for (const [size, button, icon] of [["md", 40, 21], ["sm", 34, 19], ["xs", 30, 17], ["xxs", 26, 15], ["micro", 22, 12]]) {
|
||||||
|
const target = page.locator(`.nds-icon-button[data-size="${size}"]`);
|
||||||
|
await expect(target).toHaveCSS("width", `${button}px`);
|
||||||
|
await expect(target).toHaveCSS("height", `${button}px`);
|
||||||
|
await expect(target.locator("i")).toHaveCSS("width", `${icon}px`);
|
||||||
|
await expect(target.locator("i")).toHaveCSS("height", `${icon}px`);
|
||||||
|
await target.hover();
|
||||||
|
await expect(target).toHaveCSS("background-image", /139, 92, 246/);
|
||||||
|
await expect(target).toHaveCSS("width", `${button}px`);
|
||||||
|
}
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
for (const [size, height] of [["md", 42], ["sm", 34], ["xs", 30]]) {
|
||||||
|
await expect(page.locator(`.nds-button[data-size="${size}"]`)).toHaveCSS("min-height", `${height}px`);
|
||||||
|
}
|
||||||
|
await expect(page.locator('[data-variant="primary"]')).toHaveCSS("background-image", /246, 196, 83/);
|
||||||
|
await expect(page.locator('[data-variant="danger"]')).toHaveCSS("color", "rgb(251, 113, 133)");
|
||||||
|
await expect(page.locator('[data-variant="ghost"]')).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||||
|
await expect(page.locator('[data-variant="dashed"]')).toHaveCSS("border-top-style", "dashed");
|
||||||
|
const toggle = page.locator('[data-variant="secondary"]');
|
||||||
|
await toggle.evaluate((element) => element.setAttribute("aria-pressed", "true"));
|
||||||
|
await expect(toggle).toHaveCSS("background-image", /246, 196, 83/);
|
||||||
|
await toggle.evaluate((element) => { element.disabled = true; });
|
||||||
|
await expect(toggle).toHaveCSS("cursor", "not-allowed");
|
||||||
|
await expect(page.locator("#edge")).toHaveCSS("border-top-right-radius", "0px");
|
||||||
|
await expect(page.locator("#edge")).toHaveCSS("border-right-color", "rgba(0, 0, 0, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switches and native checkboxes expose checked and disabled states at each size", async ({ page }) => {
|
||||||
|
for (const [size, height, width, trackHeight, off, on] of [["md", 34, 34, 18, 3, 17], ["lg", 40, 30, 16, 3, 15], ["sm", 28, 24, 14, 2, 12], ["xs", 26, 18, 12, 2, 7]]) {
|
||||||
|
await mountStyles(page, `<button style="margin:24px" class="nds-switch" data-size="${size}" aria-pressed="false"><span class="ui-icon"></span><i></i></button>`);
|
||||||
|
const control = page.locator("button");
|
||||||
|
const track = control.locator("i");
|
||||||
|
await expect(control).toHaveCSS("min-height", `${height}px`);
|
||||||
|
await expect(track).toHaveCSS("width", `${width}px`);
|
||||||
|
await expect(track).toHaveCSS("height", `${trackHeight}px`);
|
||||||
|
await expectPseudo(track, "::before", "left", `${off}px`);
|
||||||
|
await control.evaluate((element) => element.setAttribute("aria-pressed", "true"));
|
||||||
|
await expectPseudo(track, "::before", "left", `${on}px`);
|
||||||
|
await expectPseudo(track, "::before", "background-color", "rgb(246, 196, 83)");
|
||||||
|
}
|
||||||
|
await mountStyles(page, `<div style="padding:24px">
|
||||||
|
${["md", "sm", "xs"].map((size) => `<input type="checkbox" class="nds-checkbox" data-size="${size}" aria-label="${size}">`).join("")}
|
||||||
|
<label class="nds-native-switch"><input type="checkbox"><span></span><b>Native</b></label>
|
||||||
|
</div>`);
|
||||||
|
for (const [size, dimension] of [["md", 20], ["sm", 18], ["xs", 14]]) {
|
||||||
|
const checkbox = page.getByRole("checkbox", { name: size, exact: true });
|
||||||
|
await expect(checkbox).toHaveCSS("width", `${dimension}px`);
|
||||||
|
await expect(checkbox).toHaveCSS("height", `${dimension}px`);
|
||||||
|
await expectPseudo(checkbox, "::before", "transform", "matrix(0, 0, 0, 0, 0, 0)");
|
||||||
|
await checkbox.check();
|
||||||
|
await expectPseudo(checkbox, "::before", "transform", "matrix(1, 0, 0, 1, 0, 0)");
|
||||||
|
await checkbox.evaluate((element) => { element.disabled = true; });
|
||||||
|
await expect(checkbox).toHaveCSS("cursor", "not-allowed");
|
||||||
|
}
|
||||||
|
await page.getByText("Native", { exact: true }).click();
|
||||||
|
const native = page.locator(".nds-native-switch");
|
||||||
|
await expect(native.locator("input")).toBeChecked();
|
||||||
|
await expectPseudo(native.locator("span"), "::before", "left", "19px");
|
||||||
|
await native.locator("input").evaluate((element) => { element.disabled = true; });
|
||||||
|
await expect(native).toHaveCSS("cursor", "not-allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("steppers, radio groups and compound fields retain compact geometry", async ({ page }) => {
|
||||||
|
for (const [size, buttonSize, optionHeight] of [["md", 28, 34], ["sm", 18, 28], ["xs", 16, 26]]) {
|
||||||
|
await mountStyles(page, `<div style="padding:24px">
|
||||||
|
<div class="nds-quantity-stepper" data-size="${size}"><button>-</button><span class="nds-quantity-stepper-value"><input type="number" value="1" class="nds-quantity-stepper-input"></span><button>+</button></div>
|
||||||
|
<div class="nds-radio-button-group" data-size="${size}"><button>A</button><button class="active">B</button></div>
|
||||||
|
<div class="nds-compound-toggle-field"><button class="active">Hold</button><input type="number" class="nds-compound-toggle-field-input"></div>
|
||||||
|
</div>`);
|
||||||
|
for (const button of await page.locator(".nds-quantity-stepper button").all()) {
|
||||||
|
await expect(button).toHaveCSS("width", `${buttonSize}px`);
|
||||||
|
await expect(button).toHaveCSS("height", `${buttonSize}px`);
|
||||||
|
}
|
||||||
|
await expect(page.locator(".nds-radio-button-group > button").first()).toHaveCSS("min-height", `${optionHeight}px`);
|
||||||
|
await expect(page.locator(".nds-radio-button-group > .active")).toHaveCSS("background-image", /246, 196, 83/);
|
||||||
|
await expect(page.locator(".nds-compound-toggle-field-input")).toHaveCSS("min-height", "26px");
|
||||||
|
await expect(page.locator(".nds-compound-toggle-field-input")).toHaveCSS("width", "34px");
|
||||||
|
await expect(page.locator(".nds-compound-toggle-field > button")).toHaveCSS("background-image", /246, 196, 83/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("palettes, inline fields and autocomplete menus render their specialised states", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<div style="padding:24px">
|
||||||
|
<button class="nds-swatch" style="--swatch-color:#22d3ee"></button>
|
||||||
|
<button class="nds-swatch" data-variant="rainbow"></button>
|
||||||
|
<button class="nds-large-icon-picker-trigger"><i class="ui-icon"></i></button>
|
||||||
|
<input class="nds-inline-input" type="text" value="Inline">
|
||||||
|
<textarea class="nds-textarea" data-variant="ghost">Memo</textarea>
|
||||||
|
<div class="nds-autocomplete-field"><input class="nds-input" type="text"><div class="nds-autocomplete-menu"><button class="nds-autocomplete-option">Choice</button></div></div>
|
||||||
|
</div>`);
|
||||||
|
await expectPseudo(page.locator(".nds-swatch").first(), "::before", "background-color", "rgb(34, 211, 238)");
|
||||||
|
expect(await computed(page.locator('[data-variant="rainbow"]'), "background-image", "::before")).toContain("conic-gradient");
|
||||||
|
await expect(page.locator(".nds-large-icon-picker-trigger")).toHaveCSS("width", "100px");
|
||||||
|
await expect(page.locator(".nds-large-icon-picker-trigger i")).toHaveCSS("width", "72px");
|
||||||
|
await expect(page.locator(".nds-inline-input")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||||
|
await page.locator(".nds-inline-input").focus();
|
||||||
|
await expect(page.locator(".nds-inline-input")).toHaveCSS("border-top-color", "rgb(139, 92, 246)");
|
||||||
|
await expect(page.locator("textarea")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||||
|
await expect(page.locator(".nds-autocomplete-menu")).toHaveCSS("position", "absolute");
|
||||||
|
await expect(page.locator(".nds-autocomplete-menu")).toHaveCSS("overflow-y", "auto");
|
||||||
|
await page.locator(".nds-autocomplete-option").hover();
|
||||||
|
await expect(page.locator(".nds-autocomplete-option")).toHaveCSS("background-color", "rgba(246, 196, 83, 0.1)");
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await page.locator(".nds-autocomplete-option").evaluate((element) => element.classList.add("is-active"));
|
||||||
|
await expect(page.locator(".nds-autocomplete-option")).toHaveCSS("background-color", "rgba(246, 196, 83, 0.1)");
|
||||||
|
});
|
||||||
165
e2e/nds-patterns.spec.js
Normal file
165
e2e/nds-patterns.spec.js
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// Rôle : vérifie les surfaces, feedbacks, overlays et préférences visuelles compilés.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { mountStyles, computed, expectPseudo, expectFits } from "./fixtures/compiled-styles.js";
|
||||||
|
|
||||||
|
test("panels, tool surfaces and heroes keep layered non-intercepting decoration", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<main>
|
||||||
|
<section class="nds-panel"><button>Panel</button></section>
|
||||||
|
<section class="nds-tool-panel" data-variant="interactive"><button>Tool</button></section>
|
||||||
|
<section class="nds-filter-panel"><button>Filter</button></section>
|
||||||
|
<header class="nds-page-hero"><h1>Hero</h1></header>
|
||||||
|
<div class="nds-toolbar"><button>A</button><button>B</button></div>
|
||||||
|
<div class="nds-section-heading"><h2>Heading</h2><button>Action</button></div>
|
||||||
|
<div class="nds-empty-state">Empty</div>
|
||||||
|
</main>`);
|
||||||
|
for (const selector of [".nds-panel", ".nds-tool-panel", ".nds-filter-panel", ".nds-page-hero"]) {
|
||||||
|
const surface = page.locator(selector);
|
||||||
|
await expect(surface).toHaveCSS("border-top-width", "1px");
|
||||||
|
await expect(surface).toHaveCSS("overflow", "hidden");
|
||||||
|
await expectPseudo(surface, "::before", "pointer-events", "none");
|
||||||
|
await expectPseudo(surface, "::after", "pointer-events", "none");
|
||||||
|
expect(await computed(surface, "background-image", "::after")).toContain("gradient");
|
||||||
|
await expect(surface.locator(":scope > *")).toHaveCSS("z-index", "1");
|
||||||
|
}
|
||||||
|
await expect(page.locator(".nds-tool-panel")).toHaveCSS("contain", "paint");
|
||||||
|
await page.locator(".nds-tool-panel").hover();
|
||||||
|
await expect(page.locator(".nds-tool-panel")).toHaveCSS("box-shadow", /139, 92, 246/);
|
||||||
|
await expect(page.locator(".nds-filter-panel")).toHaveCSS("backdrop-filter", "blur(10px)");
|
||||||
|
await expect(page.locator(".nds-page-hero")).toHaveCSS("min-height", "180px");
|
||||||
|
await expect(page.locator(".nds-toolbar")).toHaveCSS("flex-wrap", "wrap");
|
||||||
|
await expect(page.locator(".nds-section-heading")).toHaveCSS("justify-content", "space-between");
|
||||||
|
await expect(page.locator(".nds-empty-state")).toHaveCSS("min-height", "132px");
|
||||||
|
await expect(page.locator(".nds-empty-state")).toHaveCSS("align-content", "center");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("badges, notices, progress and chips expose meaningful visual states", async ({ page }) => {
|
||||||
|
const tones = { success: "rgb(52, 211, 153)", warning: "rgb(251, 191, 36)", danger: "rgb(251, 113, 133)" };
|
||||||
|
await mountStyles(page, `<main>
|
||||||
|
${Object.keys(tones).map((tone) => `<span class="nds-badge" data-variant="${tone}">${tone}</span><div class="nds-inline-notice" data-variant="${tone}">${tone}</div><div class="nds-progress" data-variant="${tone}" style="width:200px"><span style="width:50%"></span></div>`).join("")}
|
||||||
|
<label class="nds-chip"><input type="checkbox">Choice</label>
|
||||||
|
</main>`);
|
||||||
|
for (const [tone, color] of Object.entries(tones)) {
|
||||||
|
await expect(page.locator(`.nds-badge[data-variant="${tone}"]`)).toHaveCSS("color", color);
|
||||||
|
await expect(page.locator(`.nds-inline-notice[data-variant="${tone}"]`)).toHaveCSS("color", color);
|
||||||
|
const progress = page.locator(`.nds-progress[data-variant="${tone}"]`);
|
||||||
|
await expect(progress).toHaveCSS("height", "8px");
|
||||||
|
await expect(progress).toHaveCSS("overflow", "hidden");
|
||||||
|
expect(await computed(progress.locator("span"), "background-image")).toContain(color);
|
||||||
|
await expect(progress.locator("span")).toHaveCSS("width", "99px");
|
||||||
|
await progress.locator("span").evaluate((element) => { element.style.width = "100%"; });
|
||||||
|
await expect(progress.locator("span")).toHaveCSS("width", "198px");
|
||||||
|
await progress.evaluate((element) => { element.dataset.size = "sm"; });
|
||||||
|
await expect(progress).toHaveCSS("height", "4px");
|
||||||
|
}
|
||||||
|
const chip = page.locator(".nds-chip");
|
||||||
|
await expect(chip).toHaveCSS("background-color", "rgba(5, 7, 17, 0.32)");
|
||||||
|
await chip.locator("input").check();
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await expect(chip).toHaveCSS("background-color", "rgba(139, 92, 246, 0.14)");
|
||||||
|
await chip.evaluate((element) => {
|
||||||
|
element.dataset.size = "sm";
|
||||||
|
const image = globalThis.document.createElement("img");
|
||||||
|
image.src = "/static/icons/multiples.svg";
|
||||||
|
image.alt = "";
|
||||||
|
element.append(image);
|
||||||
|
});
|
||||||
|
await expect(chip).toHaveCSS("min-height", "30px");
|
||||||
|
await expect(chip).toHaveCSS("font-size", "12px");
|
||||||
|
await expect(chip.locator("img")).toHaveCSS("width", "16px");
|
||||||
|
await expect(chip.locator("img")).toHaveCSS("height", "16px");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [1440, 1280, 768]) {
|
||||||
|
test(`overlays fit, scroll internally and preserve stacking at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 800 });
|
||||||
|
await mountStyles(page, `<div class="confirm-modal-root">
|
||||||
|
<div class="nds-modal-backdrop"></div>
|
||||||
|
<section class="nds-modal confirm-modal"><h2>Confirm</h2><div style="height:1200px">Long content</div><footer class="nds-modal-footer"><button>Close</button></footer></section>
|
||||||
|
</div>`);
|
||||||
|
const modal = page.locator(".nds-modal");
|
||||||
|
await expectFits(modal);
|
||||||
|
await expect(modal).toHaveCSS("overflow-y", "auto");
|
||||||
|
expect((await modal.boundingBox()).height).toBeLessThanOrEqual(752);
|
||||||
|
await modal.evaluate((element) => { element.scrollTop = 300; });
|
||||||
|
expect(await modal.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||||
|
await expect(page.locator(".confirm-modal-root")).toHaveCSS("overscroll-behavior", "contain");
|
||||||
|
await expect(page.locator(".nds-modal-backdrop")).toHaveCSS("position", "fixed");
|
||||||
|
await expect(page.locator(".nds-modal-backdrop")).toHaveCSS("backdrop-filter", "blur(6px)");
|
||||||
|
await page.locator("body").evaluate((element) => element.classList.add("is-modal-open"));
|
||||||
|
await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
|
||||||
|
|
||||||
|
await mountStyles(page, `<div class="drawer ${width < 1440 ? "is-compact" : ""}" aria-hidden="false">
|
||||||
|
<section class="drawer-panel nds-drawer-panel"><div style="height:1400px">Drawer</div></section>
|
||||||
|
<div class="drawer-resize-handle nds-drawer-resize-handle"></div></div>`);
|
||||||
|
const drawer = page.locator(".drawer-panel");
|
||||||
|
await expectFits(drawer);
|
||||||
|
await expect(drawer).toHaveCSS("top", "68px");
|
||||||
|
await expect(drawer).toHaveCSS("position", "fixed");
|
||||||
|
await expect(drawer).toHaveCSS("overflow-y", "auto");
|
||||||
|
await expect(drawer).toHaveCSS("backdrop-filter", "blur(10px)");
|
||||||
|
await expect(drawer).toHaveCSS("width", width < 1440 ? `${width - (width <= 1024 ? 72 : 248)}px` : "700px");
|
||||||
|
await expect(page.locator(".drawer-resize-handle")).toHaveCSS("display", width < 1440 ? "none" : "block");
|
||||||
|
await page.locator(".drawer").evaluate((element) => element.setAttribute("aria-hidden", "true"));
|
||||||
|
await expect(drawer).toBeHidden();
|
||||||
|
|
||||||
|
await mountStyles(page, `<div class="tool-add-modal-root"><section class="nds-modal tool-add-modal">Tools</section></div>`);
|
||||||
|
await expectFits(page.locator(".tool-add-modal"));
|
||||||
|
await expect(page.locator(".tool-add-modal-root")).toHaveCSS("overscroll-behavior", "contain");
|
||||||
|
await expect(page.locator(".tool-add-modal-root")).toHaveCSS("z-index", "130");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`toast positions, popovers and reduced motion render correctly at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 800 });
|
||||||
|
await mountStyles(page, `<div class="nds-popover">Popover</div>
|
||||||
|
<div class="nds-toast-stack notification-toast-stack" data-position="right"><div class="nds-toast notification-toast"><span>Notification</span><button>OK</button></div></div>`);
|
||||||
|
const stack = page.locator(".nds-toast-stack");
|
||||||
|
const toast = page.locator(".nds-toast");
|
||||||
|
await expect(stack).toHaveCSS("position", "fixed");
|
||||||
|
await expect(stack).toHaveCSS("pointer-events", "none");
|
||||||
|
await expect(toast).toHaveCSS("pointer-events", "auto");
|
||||||
|
await expect(toast).toHaveCSS("background-image", /conic-gradient/);
|
||||||
|
await expect.poll(async () => (await toast.boundingBox()).x + (await toast.boundingBox()).width).toBeLessThanOrEqual(width);
|
||||||
|
await expect(page.locator(".nds-popover")).toHaveCSS("overflow", "hidden");
|
||||||
|
await expect(page.locator(".nds-popover")).toHaveCSS("background-color", "rgba(7, 10, 24, 0.96)");
|
||||||
|
await stack.evaluate((element) => { element.dataset.position = "left"; element.classList.add("is-left"); });
|
||||||
|
await expect(stack).toHaveCSS("left", "20px");
|
||||||
|
await expect(toast).toHaveCSS("animation-name", /notification-toast-enter-left/);
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await expect(toast).toHaveCSS("animation-duration", "1e-05s");
|
||||||
|
await expect(toast).toHaveCSS("animation-iteration-count", "1");
|
||||||
|
await expectFits(toast);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("event sizes preserve metadata density and light/dark contrast", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<main>${["md", "sm", "xs"].map((size) => `<div class="nds-event-surface" data-size="${size}"><button><strong>Title</strong><span>12:30</span></button></div>`).join("")}</main>`);
|
||||||
|
for (const [size, height] of [["md", 44], ["sm", 22], ["xs", 10]]) {
|
||||||
|
const event = page.locator(`[data-size="${size}"]`);
|
||||||
|
await expect(event).toHaveCSS("min-height", `${height}px`);
|
||||||
|
await expect(event.locator("span")).toHaveCSS("display", size === "md" ? "block" : "none");
|
||||||
|
await expect(event.locator("strong")).toHaveCSS("text-overflow", "ellipsis");
|
||||||
|
const light = await computed(event.locator("button"), "color");
|
||||||
|
await event.evaluate((element) => { element.dataset.variant = "dark"; element.style.setProperty("--nds-event-color", "#f6c453"); });
|
||||||
|
await expect.poll(() => computed(event.locator("button"), "color")).not.toBe(light);
|
||||||
|
await event.locator("button").hover();
|
||||||
|
await expect(event.locator("button")).toHaveCSS("background-color", size === "xs" ? "rgba(0, 0, 0, 0.18)" : "rgba(0, 0, 0, 0.14)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrollbars and nebula layers compile without blocking content", async ({ page }) => {
|
||||||
|
const classes = ["themed-scrollbar", "legacy-scrollbar", "nds-scrollbox", "module-content", "table-grid-wrap", "notepad-surface", "annotation-marker-list", "goal-tree-view-wrap", "filter-options"];
|
||||||
|
await mountStyles(page, classes.map((name) => `<div class="${name}" style="height:60px;overflow:auto"><div style="height:500px">Scroll</div></div>`).join(""));
|
||||||
|
for (const name of classes) {
|
||||||
|
const box = page.locator(`.${name}`);
|
||||||
|
await expect(box).toHaveCSS("scrollbar-width", "thin");
|
||||||
|
await expect(box).toHaveCSS("scrollbar-color", "rgb(88, 55, 174) rgba(0, 0, 0, 0)");
|
||||||
|
await expectPseudo(box, "::-webkit-scrollbar", "width", "6px");
|
||||||
|
await box.evaluate((element) => { element.scrollTop = 100; });
|
||||||
|
expect(await box.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
for (const pseudo of ["::before", "::after"]) {
|
||||||
|
await expectPseudo(page.locator("body"), pseudo, "position", "fixed");
|
||||||
|
await expectPseudo(page.locator("body"), pseudo, "pointer-events", "none");
|
||||||
|
expect(await computed(page.locator("body"), "background-image", pseudo)).toContain("radial-gradient");
|
||||||
|
}
|
||||||
|
});
|
||||||
74
e2e/notepad-sync.spec.js
Normal file
74
e2e/notepad-sync.spec.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Rôle : vérifie le contenu et la sélection du Bloc Note face aux changements de page et aux mises à jour externes.
|
||||||
|
/* global window, document, NodeFilter */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/notepad-sync-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountNotepadSyncProbe } = await import(url);
|
||||||
|
document.getElementById("app").style.display = "none";
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.id = "notepad-probe";
|
||||||
|
document.body.prepend(element);
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.id = "outside-editor";
|
||||||
|
button.textContent = "Outside";
|
||||||
|
document.body.append(button);
|
||||||
|
window.notepadProbe = mountNotepadSyncProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
return page.locator("#notepad-probe .notepad-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("focused page changes never save the old DOM into the new page or module", async ({ page }) => {
|
||||||
|
const editor = await setup(page);
|
||||||
|
await expect(editor).toHaveText("Alpha");
|
||||||
|
await editor.focus();
|
||||||
|
const content = await page.evaluate(() => {
|
||||||
|
window.notepadProbe.select("b");
|
||||||
|
return document.querySelector("#notepad-probe .notepad-editor").innerHTML;
|
||||||
|
});
|
||||||
|
expect(content).toBe("<p>Bravo</p>");
|
||||||
|
await editor.fill("Second edited");
|
||||||
|
const saved = await page.evaluate(() => window.notepadProbe.writes.at(-1));
|
||||||
|
expect(saved.data.pages.find((item) => item.id === "a").html).toBe("<p>Alpha</p>");
|
||||||
|
expect(saved.data.pages.find((item) => item.id === "b").text).toBe("Second edited");
|
||||||
|
await page.evaluate(() => window.notepadProbe.update({
|
||||||
|
moduleId: "other", data: { activePageId: "b", pages: [{ id: "b", title: "Other", html: "<p>Other module</p>" }] }
|
||||||
|
}));
|
||||||
|
await expect(editor).toHaveText("Other module");
|
||||||
|
await editor.fill("Other edited");
|
||||||
|
expect(await page.evaluate(() => window.notepadProbe.writes.at(-1).moduleId)).toBe("other");
|
||||||
|
await page.evaluate(() => window.notepadProbe.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("local echoes preserve selection and external content synchronizes after blur", async ({ page }) => {
|
||||||
|
const editor = await setup(page);
|
||||||
|
await editor.fill("Local text");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const node = document.createTreeWalker(document.querySelector("#notepad-probe .notepad-editor"), NodeFilter.SHOW_TEXT).nextNode();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, 2);
|
||||||
|
range.collapse(true);
|
||||||
|
const selection = document.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
window.notepadProbe.update({});
|
||||||
|
});
|
||||||
|
expect(await page.evaluate(() => document.getSelection().anchorOffset)).toBe(2);
|
||||||
|
await expect(editor).toHaveText("Local text");
|
||||||
|
const writeCount = await page.evaluate(() => window.notepadProbe.writes.length);
|
||||||
|
await page.evaluate(() => window.notepadProbe.patchPage("a", { html: "<p>External text</p>" }));
|
||||||
|
await expect(editor).toHaveText("Local text");
|
||||||
|
expect(await page.evaluate(() => document.getSelection().anchorOffset)).toBe(2);
|
||||||
|
await page.locator("#outside-editor").click();
|
||||||
|
await expect(editor).toHaveText("External text");
|
||||||
|
await expect(page.locator("#outside-editor")).toBeFocused();
|
||||||
|
expect(await page.evaluate(() => window.notepadProbe.writes.length)).toBe(writeCount);
|
||||||
|
// L'horodatage reste celui de la sauvegarde locale ; il ne suffit pas à reconnaître un écho.
|
||||||
|
await page.evaluate(() => window.notepadProbe.patchPage("a", { html: "<p>Another update</p>" }));
|
||||||
|
await expect(editor).toHaveText("Another update");
|
||||||
|
await page.evaluate(() => window.notepadProbe.unmount());
|
||||||
|
});
|
||||||
98
e2e/onboarding.spec.js
Normal file
98
e2e/onboarding.spec.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Rôle : vérifie la prise en main, son carrousel et son intégration à la home et à la page À propos.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { URL } from "node:url";
|
||||||
|
|
||||||
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
const content = site.home.onboarding;
|
||||||
|
|
||||||
|
async function setup(page, route = "/") {
|
||||||
|
await page.clock.install({ time: new Date("2026-09-12T12:00:00Z") });
|
||||||
|
await page.clock.pauseAt(new Date("2026-09-12T12:01:00Z"));
|
||||||
|
await page.goto(route);
|
||||||
|
const section = page.getByRole("region", { name: content.title, exact: true });
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSlide(section, index) {
|
||||||
|
const slide = content.slides[index];
|
||||||
|
await expect(section.getByRole("button", { name: `${content.slideButtonLabel} ${index + 1}`, exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
const image = section.getByRole("img", { name: slide.alt, exact: true });
|
||||||
|
await expect(image).toHaveAttribute("src", slide.src);
|
||||||
|
await expect.poll(() => image.evaluate((element) => element.naturalWidth)).toBeGreaterThan(0);
|
||||||
|
await expect(section.getByText(slide.title, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("onboarding cycles, pauses on hover and focus, and allows manual selection", async ({ page }) => {
|
||||||
|
const section = await setup(page);
|
||||||
|
await expectSlide(section, 0);
|
||||||
|
await page.clock.runFor(4500);
|
||||||
|
await expectSlide(section, 1);
|
||||||
|
await section.getByRole("img", { name: content.slides[1].alt, exact: true }).hover();
|
||||||
|
await page.clock.runFor(9000);
|
||||||
|
await expectSlide(section, 1);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await page.clock.runFor(4500);
|
||||||
|
await expectSlide(section, 2);
|
||||||
|
const first = section.getByRole("button", { name: `${content.slideButtonLabel} 1`, exact: true });
|
||||||
|
await first.focus();
|
||||||
|
await page.clock.runFor(9000);
|
||||||
|
await expectSlide(section, 2);
|
||||||
|
await first.press("Enter");
|
||||||
|
await expectSlide(section, 0);
|
||||||
|
await section.getByRole("button", { name: content.primaryAction, exact: true }).focus();
|
||||||
|
await page.clock.runFor(4500);
|
||||||
|
await expectSlide(section, 1);
|
||||||
|
await page.clock.runFor(4500 * (content.slides.length - 1));
|
||||||
|
await expectSlide(section, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reduced motion keeps previews still but manual selection remains available", async ({ page }) => {
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
const section = await setup(page);
|
||||||
|
await page.clock.runFor(18000);
|
||||||
|
await expectSlide(section, 0);
|
||||||
|
await section.getByRole("button", { name: `${content.slideButtonLabel} 3`, exact: true }).click();
|
||||||
|
await expectSlide(section, 2);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await section.getByRole("button", { name: content.primaryAction, exact: true }).focus();
|
||||||
|
await page.clock.runFor(18000);
|
||||||
|
await expectSlide(section, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("home onboarding creates the initial tools and starts the tutorial", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const section = page.getByRole("region", { name: content.title, exact: true });
|
||||||
|
await section.getByRole("button", { name: content.primaryAction, exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await dialog.getByRole("textbox", { name: "Nom", exact: true }).fill("Prise en main");
|
||||||
|
await dialog.getByRole("button", { name: "Créer", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/#\/toolbox\//);
|
||||||
|
await expect(page.locator('[data-module-type="memos"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-module-type="links"]')).toBeVisible();
|
||||||
|
await expect(page.locator(".toolbox-tutorial-count")).toHaveText(/^1\//);
|
||||||
|
await page.goto("/#/");
|
||||||
|
await expect(page.getByRole("region", { name: content.title, exact: true })).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("region", { name: content.title, exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("about reuses onboarding without creation and exposes tool examples and destinations", async ({ page }) => {
|
||||||
|
await page.goto("/#/about");
|
||||||
|
await expect(page.getByRole("heading", { name: site.about.title, exact: true })).toBeVisible();
|
||||||
|
const section = page.getByRole("region", { name: content.title, exact: true });
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
await expect(section.getByRole("button", { name: content.primaryAction, exact: true })).toHaveCount(0);
|
||||||
|
for (const group of site.about.toolGroups) {
|
||||||
|
await expect(page.getByRole("list", { name: `Exemples pour ${group.title}`, exact: true }).getByRole("listitem")).toHaveText(group.examples);
|
||||||
|
}
|
||||||
|
for (const card of content.cards) {
|
||||||
|
await section.getByRole("link").filter({ hasText: card.title }).click();
|
||||||
|
await expect.poll(() => new URL(page.url()).hash).toBe(card.href);
|
||||||
|
await page.goBack();
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
}
|
||||||
|
await page.getByRole("link", { name: site.about.toolsLibraryLink, exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/#\/library$/);
|
||||||
|
});
|
||||||
48
e2e/persistence.spec.js
Normal file
48
e2e/persistence.spec.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// Rôle : vérifie l'atomicité des snapshots avec une vraie transaction IndexedDB.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
test("a synchronous snapshot error aborts all its IndexedDB writes", async ({ page }) => {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
await db.setToolboxSnapshot([{ id: "saved", modules: [] }], { game: "saved" }, { "saved:note": { text: "Keep" } });
|
||||||
|
let errorName = "";
|
||||||
|
try {
|
||||||
|
await db.setToolboxSnapshot([{ id: "broken" }], {}, { "broken:note": { invalid: () => {} } });
|
||||||
|
} catch (error) {
|
||||||
|
errorName = error.name;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
errorName,
|
||||||
|
toolboxes: await db.getToolboxes(),
|
||||||
|
links: await db.getLinks(),
|
||||||
|
modules: await db.getAllModuleData()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
errorName: "DataCloneError",
|
||||||
|
toolboxes: [{ id: "saved", modules: [] }],
|
||||||
|
links: { game: "saved" },
|
||||||
|
modules: [{ key: "saved:note", data: { text: "Keep" } }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial snapshots preserve deferred values and roll back deletes on failure", async ({ page }) => {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { media: { image: "Keep" }, removed: { text: "Delete" } });
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
await db.setToolboxSnapshot([{ id: "broken" }], {}, { broken: { invalid: () => {} } }, ["media"]);
|
||||||
|
} catch { failed = true; }
|
||||||
|
const afterFailure = await db.getAllModuleData();
|
||||||
|
await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { note: { text: "New" } }, ["media"]);
|
||||||
|
return { failed, afterFailure, afterSuccess: await db.getAllModuleData() };
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ failed: true,
|
||||||
|
afterFailure: [{ key: "media", data: { image: "Keep" } }, { key: "removed", data: { text: "Delete" } }],
|
||||||
|
afterSuccess: [{ key: "media", data: { image: "Keep" } }, { key: "note", data: { text: "New" } }]
|
||||||
|
});
|
||||||
|
});
|
||||||
37
e2e/production/asset-recovery.spec.js
Normal file
37
e2e/production/asset-recovery.spec.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// Rôle : vérifie qu'un ancien HTML rétablit l'application quand son bundle Vite a disparu.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
|
||||||
|
test("a stale entry bundle triggers one fresh document load", async ({ page }) => {
|
||||||
|
let staleDocumentServed = false;
|
||||||
|
let documentRequestCount = 0;
|
||||||
|
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const requestUrl = new globalThis.URL(request.url());
|
||||||
|
const isRootDocument = request.resourceType() === "document" && requestUrl.pathname === "/";
|
||||||
|
if (isRootDocument) documentRequestCount += 1;
|
||||||
|
|
||||||
|
if (staleDocumentServed || !isRootDocument) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
staleDocumentServed = true;
|
||||||
|
const response = await route.fetch();
|
||||||
|
const html = await response.text();
|
||||||
|
await route.fulfill({
|
||||||
|
response,
|
||||||
|
body: html.replace(/(<script type="module"[^>]*src=")[^"]+/, "$1/assets/index-stale.js")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/").catch(() => {});
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: site.home.hero.title, exact: true })).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/$|\/#\/$/);
|
||||||
|
expect(staleDocumentServed).toBe(true);
|
||||||
|
expect(documentRequestCount).toBe(2);
|
||||||
|
});
|
||||||
41
e2e/production/onboarding.spec.js
Normal file
41
e2e/production/onboarding.spec.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Rôle : vérifie la visibilité de la prise en main sur le vrai build après création et suppression persistées.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
|
||||||
|
test("production home hides onboarding after creation and restores it after deleting the last toolbox", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/");
|
||||||
|
const onboarding = page.getByRole("region", { name: site.home.onboarding.title, exact: true });
|
||||||
|
await expect(onboarding).toBeVisible();
|
||||||
|
await expect(page.locator('script[type="module"][src^="/assets/"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('script[src*="/@vite/client"]')).toHaveCount(0);
|
||||||
|
await onboarding.getByRole("button", { name: site.home.onboarding.primaryAction, exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await dialog.getByRole("textbox", { name: "Nom", exact: true }).fill("Production toolbox");
|
||||||
|
await dialog.getByRole("button", { name: "Créer", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/#\/toolbox\//);
|
||||||
|
await expect(page.locator('[data-module-type="memos"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-module-type="links"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/#/");
|
||||||
|
await expect(page.getByRole("heading", { name: site.home.hero.title, exact: true })).toBeVisible();
|
||||||
|
await expect(onboarding).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("heading", { name: site.home.hero.title, exact: true })).toBeVisible();
|
||||||
|
await expect(onboarding).toHaveCount(0);
|
||||||
|
await page.goto("/#/about");
|
||||||
|
await expect(onboarding).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByRole("button", { name: "Supprimer Production toolbox", exact: true }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Supprimer", exact: true }).click();
|
||||||
|
await expect(page.locator(".toolbox-card")).toHaveCount(0);
|
||||||
|
await page.goto("/#/");
|
||||||
|
await expect(onboarding).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(onboarding).toBeVisible();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
92
e2e/session-state.spec.js
Normal file
92
e2e/session-state.spec.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// Rôle : vérifie l'isolation de l'état de session et des dessins entre pages du Bloc Note.
|
||||||
|
/* global window, document, sessionStorage */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/keyed-state-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
test("keyed state never commits another key's value under StrictMode", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
sessionStorage.setItem("keyed-probe:a", '["A"]');
|
||||||
|
sessionStorage.setItem("keyed-probe:b", '["B"]');
|
||||||
|
const { mountKeyedStateProbe } = await import(url);
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.prepend(element);
|
||||||
|
window.keyedProbe = mountKeyedStateProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
await page.evaluate(() => window.keyedProbe.append("a", "edit"));
|
||||||
|
const reads = await page.evaluate(() => window.keyedProbe.reads);
|
||||||
|
await page.evaluate(() => window.keyedProbe.update("a"));
|
||||||
|
expect(await page.evaluate(() => window.keyedProbe.reads)).toBe(reads);
|
||||||
|
expect(await page.evaluate(() => window.keyedProbe.snapshots.at(-1).value)).toEqual(["A", "edit"]);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.keyedProbe.update("b"));
|
||||||
|
const snapshots = await page.evaluate(() => window.keyedProbe.snapshots);
|
||||||
|
expect(snapshots.length).toBeGreaterThan(0);
|
||||||
|
for (const snapshot of snapshots) expect(snapshot).toEqual({ key: "b", value: ["B"] });
|
||||||
|
await page.evaluate(() => window.keyedProbe.append("a", "stale"));
|
||||||
|
await page.evaluate(() => window.keyedProbe.append("b", "current"));
|
||||||
|
expect(await page.evaluate(() => window.keyedProbe.snapshots.at(-1).value)).toEqual(["B", "current"]);
|
||||||
|
await page.evaluate(() => window.keyedProbe.update("a"));
|
||||||
|
expect(await page.evaluate(() => window.keyedProbe.snapshots.at(-1).value)).toEqual(["A"]);
|
||||||
|
await page.evaluate(() => window.keyedProbe.unmount());
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notepad temporary drawings stay on their own pages and survive reload", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
await db.setToolboxSnapshot([{
|
||||||
|
id: "session-notes", name: "Session notes", modules: [{ id: "note", type: "notepad" }]
|
||||||
|
}], {}, {
|
||||||
|
"session-notes:note": {
|
||||||
|
activePageId: "a",
|
||||||
|
pages: ["a", "b", "c"].map((id) => ({
|
||||||
|
id, title: `Page ${id}`, html: `<p>Content ${id}</p>`, drawingMode: "temporary"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const [id, y, color] of [["a", 20, "#f6c453"], ["b", 40, "#22d3ee"]]) {
|
||||||
|
sessionStorage.setItem(`sokkog:notepad-drawings:session-notes:note:${id}`, JSON.stringify([
|
||||||
|
{ id, color, width: 4, points: [{ x: 20, y }, { x: 100, y }] }
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
sessionStorage.setItem("sokkog:notepad-drawings:session-notes:note:c", "invalid json");
|
||||||
|
});
|
||||||
|
await page.goto("/#/toolbox/session-notes");
|
||||||
|
await page.reload();
|
||||||
|
const notepad = page.locator('[data-module-type="notepad"]');
|
||||||
|
const canvas = notepad.locator("canvas");
|
||||||
|
const alphaAt = (y) => canvas.evaluate((element, y) => {
|
||||||
|
const ratio = element.width / element.clientWidth;
|
||||||
|
return element.getContext("2d").getImageData(Math.round(50 * ratio), Math.round(y * ratio), 1, 1).data[3];
|
||||||
|
}, y);
|
||||||
|
async function selectPage(id) {
|
||||||
|
await notepad.getByRole("button", { name: "Afficher les pages du Bloc Note", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: `Page ${id}`, exact: true }).click();
|
||||||
|
await expect(notepad.locator(".notepad-editor")).toHaveText(`Content ${id}`);
|
||||||
|
}
|
||||||
|
await expect(canvas).toBeVisible();
|
||||||
|
await expect.poll(() => alphaAt(20)).toBeGreaterThan(0);
|
||||||
|
await selectPage("b");
|
||||||
|
await expect.poll(() => alphaAt(40)).toBeGreaterThan(0);
|
||||||
|
await expect.poll(() => alphaAt(20)).toBe(0);
|
||||||
|
await notepad.getByRole("button", { name: "Dessiner", exact: true }).click();
|
||||||
|
await notepad.getByRole("button", { name: "Effacer les dessins", exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => JSON.parse(sessionStorage.getItem("sokkog:notepad-drawings:session-notes:note:b")))).toEqual([]);
|
||||||
|
await selectPage("a");
|
||||||
|
await expect.poll(() => alphaAt(20)).toBeGreaterThan(0);
|
||||||
|
await selectPage("c");
|
||||||
|
await expect.poll(() => alphaAt(20)).toBe(0);
|
||||||
|
await selectPage("a");
|
||||||
|
await page.reload();
|
||||||
|
await expect.poll(() => alphaAt(20)).toBeGreaterThan(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
108
e2e/shell-behavior.spec.js
Normal file
108
e2e/shell-behavior.spec.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Rôle : couvre les durées des alertes, le nettoyage des overlays et le rendu éditorial sans inspecter leur source.
|
||||||
|
/* global window, document, CustomEvent */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/shell-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
async function setup(page, probe = false) {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".app-shell")).toBeVisible();
|
||||||
|
if (probe) {
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountShellProbe } = await import(url);
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.id = "shell-probe";
|
||||||
|
document.body.append(element);
|
||||||
|
window.shellProbe = mountShellProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
}
|
||||||
|
await page.clock.install({ time: new Date("2026-09-12T12:00:00Z") });
|
||||||
|
await page.clock.pauseAt(new Date("2026-09-12T12:00:01Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notify(page, message, options = {}) {
|
||||||
|
await page.evaluate((detail) => window.dispatchEvent(new CustomEvent("sokkog:notify", { detail })), { message, ...options });
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: message })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ordinary alerts last six seconds while explicit and persistent durations are respected", async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
await notify(page, "Texte copié");
|
||||||
|
await notify(page, "Sauvegarde terminée");
|
||||||
|
await page.clock.runFor(5999);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(2);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(0);
|
||||||
|
|
||||||
|
await notify(page, "Minimum", { durationMs: 0 });
|
||||||
|
await notify(page, "Personnalisée", { durationMs: 2000 });
|
||||||
|
await page.clock.runFor(999);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(2);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
await expect(page.getByRole("status")).toHaveText("Personnalisée");
|
||||||
|
await page.getByRole("button", { name: "Fermer l'alerte" }).dispatchEvent("click");
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(0);
|
||||||
|
await page.clock.runFor(2000);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(0);
|
||||||
|
|
||||||
|
await notify(page, "Alerte persistante", { persistent: true });
|
||||||
|
await page.clock.runFor(120000);
|
||||||
|
await expect(page.getByRole("status")).toHaveText("Alerte persistante");
|
||||||
|
await page.getByRole("button", { name: "Fermer l'alerte" }).click();
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(0);
|
||||||
|
|
||||||
|
for (let index = 1; index <= 6; index += 1) await notify(page, `Pile ${index}`);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(5);
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "Pile 1" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "Pile 6" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toast replacement and unmount cancel obsolete expiration callbacks under StrictMode", async ({ page }) => {
|
||||||
|
await setup(page, true);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ toast: { id: "old", message: "Ancienne", durationMs: 1000 } }));
|
||||||
|
await page.clock.runFor(500);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ toast: { id: "new", message: "Nouvelle", durationMs: 2000 } }));
|
||||||
|
await page.clock.runFor(1999);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual([]);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new"]);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ toast: { id: "default", message: "Par défaut" } }));
|
||||||
|
await page.clock.runFor(5999);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new"]);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new", "default"]);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.shellProbe.render({ toast: { id: "unmounted", message: "Fermée" } });
|
||||||
|
window.shellProbe.unmount();
|
||||||
|
});
|
||||||
|
await page.clock.runFor(120000);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new", "default"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overlapping modals keep scroll locked until the last unmount under StrictMode", async ({ page }) => {
|
||||||
|
await setup(page, true);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ confirm: true, create: true }));
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(2);
|
||||||
|
await expect(page.locator("body")).toHaveCSS("overflow-y", "hidden");
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ create: true }));
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(1);
|
||||||
|
await expect(page.locator("body")).toHaveCSS("overflow-y", "hidden");
|
||||||
|
await page.evaluate(() => window.shellProbe.unmount());
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
await expect(page.locator("body")).not.toHaveCSS("overflow-y", "hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editorial text preserves line breaks and italics but never interprets arbitrary HTML", async ({ page }) => {
|
||||||
|
await setup(page, true);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ text: 'Première\n<i>Seconde</i>\n<img src=x onerror="alert(1)">' }));
|
||||||
|
const text = page.locator("#editorial-text");
|
||||||
|
await expect(text.locator("br")).toHaveCount(2);
|
||||||
|
await expect(text.locator("i")).toHaveText("Seconde");
|
||||||
|
await expect(text.locator("img, script")).toHaveCount(0);
|
||||||
|
await expect(text).toContainText('<img src=x onerror="alert(1)">');
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ text: "Remplacé" }));
|
||||||
|
await expect(text).toHaveText("Remplacé");
|
||||||
|
await expect(text.locator("br, i")).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.shellProbe.unmount());
|
||||||
|
});
|
||||||
143
e2e/storage-startup.spec.js
Normal file
143
e2e/storage-startup.spec.js
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// Rôle : vérifie qu'une panne IndexedDB ne peut pas transformer les données existantes en un état vide modifiable.
|
||||||
|
/* global window, document, IDBFactory, IDBObjectStore, DOMException, Buffer */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/storage-startup-probe.js", import.meta.url))}`;
|
||||||
|
const original = {
|
||||||
|
toolboxes: [{ id: "saved", name: "Conservée", updatedAt: "2026-09-12T12:00:00.000Z", modules: [{ id: "note", type: "memos", title: "Mémos" }] }],
|
||||||
|
links: { mhwilds: "saved" },
|
||||||
|
modules: [{ key: "saved:note", data: { items: [{ id: "memo", text: "Ne pas perdre" }] } }]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(page, fault) {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.evaluate(async (data) => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
await db.setToolboxSnapshot(data.toolboxes, data.links, Object.fromEntries(data.modules.map((entry) => [entry.key, entry.data])));
|
||||||
|
}, original);
|
||||||
|
await page.addInitScript((fault) => {
|
||||||
|
window.storageFault = fault;
|
||||||
|
const open = IDBFactory.prototype.open;
|
||||||
|
IDBFactory.prototype.open = function (...args) {
|
||||||
|
if (window.storageFault === "open") throw new DOMException("Ouverture indisponible", "UnknownError");
|
||||||
|
return open.apply(this, args);
|
||||||
|
};
|
||||||
|
const getAll = IDBObjectStore.prototype.getAll;
|
||||||
|
IDBObjectStore.prototype.getAll = function (...args) {
|
||||||
|
if (window.storageFault === "read" && this.name === "modules") throw new DOMException("Lecture indisponible", "UnknownError");
|
||||||
|
return getAll.apply(this, args);
|
||||||
|
};
|
||||||
|
const put = IDBObjectStore.prototype.put;
|
||||||
|
IDBObjectStore.prototype.put = function (...args) {
|
||||||
|
if (window.storageFault === "write") throw new DOMException("Écriture indisponible", "UnknownError");
|
||||||
|
return put.apply(this, args);
|
||||||
|
};
|
||||||
|
}, fault);
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshot(page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const fault = window.storageFault;
|
||||||
|
window.storageFault = "";
|
||||||
|
try {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
return { toolboxes: await db.getToolboxes(), links: await db.getLinks(), modules: await db.getAllModuleData() };
|
||||||
|
} finally {
|
||||||
|
window.storageFault = fault;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fault of ["open", "read"]) {
|
||||||
|
test(`initial ${fault} failure blocks the interface and retries without changing persisted data`, async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize(fault === "read" ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
||||||
|
await setup(page, fault);
|
||||||
|
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("alert")).toHaveText(fault === "open" ? "Ouverture indisponible" : "Lecture indisponible");
|
||||||
|
await expect(page.getByRole("button", { name: /Nouvelle toolbox|Exporter tout|Importer/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Aucune toolbox", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("storage-unavailable.png"), fullPage: true });
|
||||||
|
await page.getByRole("button", { name: "Réessayer" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||||
|
await page.evaluate(() => { window.location.hash = "/toolbox/saved"; });
|
||||||
|
await expect(page.getByRole("heading", { name: "Toolbox introuvable" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||||
|
await page.evaluate(() => { window.storageFault = ""; });
|
||||||
|
await page.getByRole("button", { name: "Réessayer" }).click();
|
||||||
|
await expect(page.locator('[data-module-type="memos"]')).toBeVisible();
|
||||||
|
await expect(page.getByText("Ne pas perdre", { exact: true }).first()).toBeVisible();
|
||||||
|
expect(await snapshot(page)).toEqual(original);
|
||||||
|
|
||||||
|
await page.evaluate(() => { window.location.hash = "/toolboxes"; });
|
||||||
|
const downloadPromise = page.waitForEvent("download");
|
||||||
|
await page.getByRole("button", { name: "Exporter tout", exact: true }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
const stream = await download.createReadStream();
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of stream) chunks.push(chunk);
|
||||||
|
const exported = JSON.parse(Buffer.concat(chunks).toString());
|
||||||
|
expect(exported.toolboxes[0].id).toBe("saved");
|
||||||
|
expect(exported.modules["saved:note"].items[0].text).toBe("Ne pas perdre");
|
||||||
|
expect(await snapshot(page)).toEqual(original);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Après reprise");
|
||||||
|
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await snapshot(page)).toolboxes.length).toBe(2);
|
||||||
|
const saved = await snapshot(page);
|
||||||
|
expect(saved.toolboxes.find((toolbox) => toolbox.id === "saved").name).toBe("Conservée");
|
||||||
|
expect(saved.modules.find((entry) => entry.key === "saved:note")).toEqual(original.modules[0]);
|
||||||
|
expect(saved.links).toEqual(original.links);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("commands reject writes, creation, imports and exports while the store is not initialized", async ({ page }) => {
|
||||||
|
await setup(page, "read");
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountStorageStartupProbe } = await import(url);
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.append(element);
|
||||||
|
window.storageProbe = mountStorageStartupProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
await expect.poll(() => page.evaluate(() => window.storageProbe.store.loadError)).toBe("Lecture indisponible");
|
||||||
|
const results = await page.evaluate(async () => {
|
||||||
|
const { store, actions } = window.storageProbe;
|
||||||
|
const forbidden = () => { throw new Error("Incomplete state must not be consumed"); };
|
||||||
|
return {
|
||||||
|
ready: store.ready,
|
||||||
|
writes: await Promise.all([
|
||||||
|
store.setToolboxes(forbidden), store.setLinks(forbidden), store.setToastPosition("left"),
|
||||||
|
store.updateModuleData("saved", "note", {}), store.removeModuleData("saved", "note"),
|
||||||
|
store.removeToolboxModuleData({ id: "saved", modules: [{ id: "note" }] }),
|
||||||
|
store.persistToolboxSnapshot(forbidden)
|
||||||
|
]),
|
||||||
|
created: actions.createToolbox("New"),
|
||||||
|
imported: await actions.importToolbox({ text: forbidden }),
|
||||||
|
importedAll: await actions.importAllToolboxes({ text: forbidden }),
|
||||||
|
exported: await actions.exportToolbox("saved"), exportedAll: await actions.exportAllToolboxes(),
|
||||||
|
notices: window.storageProbe.notices
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(results).toEqual({ ready: false, writes: Array(7).fill(false), created: null, imported: null, importedAll: false, exported: false, exportedAll: false, notices: [] });
|
||||||
|
expect(await snapshot(page)).toEqual(original);
|
||||||
|
await page.evaluate(() => window.storageProbe.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later write failure uses an accurate title and preserves the confirmed toolbox", async ({ page }) => {
|
||||||
|
await setup(page, "");
|
||||||
|
await expect(page.locator(".toolbox-card")).toHaveCount(1);
|
||||||
|
await page.evaluate(() => { window.storageFault = "write"; });
|
||||||
|
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Not saved");
|
||||||
|
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Erreur de stockage local" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "Compris", exact: true }).click();
|
||||||
|
expect(await snapshot(page)).toEqual(original);
|
||||||
|
});
|
||||||
192
e2e/style-layouts.spec.js
Normal file
192
e2e/style-layouts.spec.js
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
// Rôle : vérifie les layouts responsive réels et les contraintes de placement compilées.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { mountStyles, computed, expectFits } from "./fixtures/compiled-styles.js";
|
||||||
|
|
||||||
|
async function columns(locator, count) {
|
||||||
|
await expect(locator).toHaveCSS("display", "grid");
|
||||||
|
await expect.poll(async () => (await computed(locator, "grid-template-columns")).split(" ").length).toBe(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("calculator layout follows the tool width rather than its location", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await mountStyles(page, `<article class="module" data-module-type="calculator" style="width: 900px"><div class="calculator-module"><form class="calculator-form"><div class="calculator-card"><div class="calculator-field">Formula</div><div class="calculator-current-field">Current</div><div class="calculator-result-field"><span>Result</span><div class="calculator-result"><strong>12</strong></div></div><label class="calculator-label-field">Label</label><button class="calculator-save-button">Save</button></div></form><div>Entries</div></div></article>`);
|
||||||
|
const tool = page.locator('[data-module-type="calculator"]');
|
||||||
|
const calculator = tool.locator(".calculator-module");
|
||||||
|
const card = tool.locator(".calculator-card");
|
||||||
|
await expect(tool).toHaveCSS("container-type", "inline-size");
|
||||||
|
await columns(calculator, 2);
|
||||||
|
await columns(card, 1);
|
||||||
|
await tool.evaluate((element) => { element.style.width = "700px"; });
|
||||||
|
await columns(calculator, 1);
|
||||||
|
await columns(card, 2);
|
||||||
|
await expect(tool.locator(".calculator-field")).toHaveCSS("grid-column-start", "1");
|
||||||
|
await expect(tool.locator(".calculator-result-field")).toHaveCSS("grid-column-start", "2");
|
||||||
|
await expect(tool.locator(".calculator-result")).toHaveCSS("height", "36px");
|
||||||
|
await expect(tool.locator(".calculator-result")).toHaveCSS("min-height", "36px");
|
||||||
|
const resultValue = tool.locator(".calculator-result strong");
|
||||||
|
const compactResultFontSize = Number.parseFloat(await computed(resultValue, "font-size"));
|
||||||
|
await expect(tool.locator(".calculator-result")).toHaveCSS("padding-top", "7px");
|
||||||
|
await tool.locator(".calculator-result-field").evaluate((element) => { element.classList.add("has-current"); });
|
||||||
|
await expect.poll(async () => Number.parseFloat(await computed(tool.locator(".calculator-result"), "height"))).toBeGreaterThan(36);
|
||||||
|
await expect.poll(async () => Number.parseFloat(await computed(resultValue, "font-size"))).toBeGreaterThan(compactResultFontSize);
|
||||||
|
await expect(tool.locator(".calculator-result")).toHaveCSS("padding-top", "12px");
|
||||||
|
await tool.evaluate((element) => { element.style.width = "480px"; });
|
||||||
|
await columns(card, 1);
|
||||||
|
await expect(tool.locator(".calculator-result")).toHaveCSS("min-height", "72px");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toolbox columns follow the toolbox content width", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await mountStyles(page, `<section class="toolbox-page" style="width: 920px">
|
||||||
|
<div class="modules-toolbar"><div class="layout-switch">Layout</div></div>
|
||||||
|
<div class="modules" data-layout-cols="2"><div class="module-column"><article class="module">A</article></div><div class="module-column"><article class="module">B</article></div></div>
|
||||||
|
</section>`);
|
||||||
|
const toolbox = page.locator(".toolbox-page");
|
||||||
|
const modules = toolbox.locator(".modules");
|
||||||
|
await columns(modules, 2);
|
||||||
|
await expect(toolbox.locator(".modules-toolbar")).toBeVisible();
|
||||||
|
await toolbox.evaluate((element) => { element.style.width = "899px"; });
|
||||||
|
await columns(modules, 1);
|
||||||
|
await expect(toolbox.locator(".modules-toolbar")).toBeHidden();
|
||||||
|
await expect(toolbox.locator(".module-column").first()).toHaveCSS("display", "contents");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [1920, 1280, 1025, 1024, 960, 768]) {
|
||||||
|
test(`home, about and library retain their responsive layout at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.goto("/");
|
||||||
|
const onboarding = page.locator(".home-onboarding");
|
||||||
|
await expectFits(onboarding);
|
||||||
|
const hero = page.locator(".hero");
|
||||||
|
const heroContent = hero.locator(":scope > div").first();
|
||||||
|
const heroStats = hero.locator(".hero-stats");
|
||||||
|
await columns(hero, width <= 1024 ? 1 : 2);
|
||||||
|
const [heroContentBounds, heroStatsBounds] = await Promise.all([heroContent.boundingBox(), heroStats.boundingBox()]);
|
||||||
|
if (width <= 1024) {
|
||||||
|
expect(heroStatsBounds.y).toBeGreaterThanOrEqual(heroContentBounds.y + heroContentBounds.height);
|
||||||
|
expect(heroStatsBounds.x).toBeCloseTo(heroContentBounds.x, 0);
|
||||||
|
} else {
|
||||||
|
expect(heroStatsBounds.x).toBeGreaterThan(heroContentBounds.x + heroContentBounds.width);
|
||||||
|
}
|
||||||
|
await columns(page.locator(".home-onboarding-layout"), width <= 1024 ? 1 : 2);
|
||||||
|
await expect(page.locator(".home-onboarding-slider figcaption")).toHaveCSS("position", "absolute");
|
||||||
|
await expect(page.locator(".home-onboarding-dots")).toHaveCSS("display", "flex");
|
||||||
|
await expect(page.locator(".home-onboarding-slider img")).toHaveCSS("object-fit", "cover");
|
||||||
|
await expect(page.locator(".home-visual")).toHaveCSS("display", width <= 1024 ? "none" : "grid");
|
||||||
|
await expect(page.locator(".origin-copy")).toHaveCSS("border-right-width", width <= 1024 ? "0px" : "2px");
|
||||||
|
await columns(page.locator(".origin-section"), width <= 1024 ? 1 : 2);
|
||||||
|
const side = page.locator(".sidebar");
|
||||||
|
await expect(side).toHaveCSS("display", "flex");
|
||||||
|
{
|
||||||
|
const responsiveCompact = width <= 1024;
|
||||||
|
if (responsiveCompact) await expect(side).toHaveAttribute("data-variant", "compact");
|
||||||
|
else await expect(side).not.toHaveAttribute("data-variant");
|
||||||
|
await expect(side).toHaveCSS("width", responsiveCompact ? "72px" : "248px");
|
||||||
|
await expect(page.locator(".sidebar-mascot")).toHaveCSS("display", responsiveCompact ? "none" : "block");
|
||||||
|
const secondaryLinks = page.locator(".sidebar-secondary-links");
|
||||||
|
await expect(secondaryLinks).toHaveCSS("display", "grid");
|
||||||
|
await expect(page.locator(".sidebar-about-link")).toHaveCount(3);
|
||||||
|
await expect(page.locator(".sidebar-about-link strong").first()).toHaveCSS("display", responsiveCompact ? "none" : "block");
|
||||||
|
await expect(page.locator(".sidebar-note")).toHaveCSS("display", "block");
|
||||||
|
await expect(page.locator(".sidebar-storage-actions")).toHaveCSS("flex-direction", responsiveCompact ? "column" : "row");
|
||||||
|
await expect(page.locator(".sidebar-action-button")).toHaveCount(2);
|
||||||
|
await expect(page.locator(".nav-item")).toHaveCount(3);
|
||||||
|
await expect(page.locator(".nav-item strong").first()).toHaveCSS("display", responsiveCompact ? "none" : "block");
|
||||||
|
const brand = page.locator(".brand");
|
||||||
|
const brandMark = page.locator(".brand-mark");
|
||||||
|
await expect(brandMark).toHaveCSS("animation-name", "none");
|
||||||
|
await expect(brandMark).toHaveCSS("transform", "none");
|
||||||
|
await brand.hover();
|
||||||
|
await expect.poll(async () => brandMark.evaluate((element) => globalThis.getComputedStyle(element).transform)).not.toBe("none");
|
||||||
|
if (!responsiveCompact) {
|
||||||
|
const storageActions = await page.locator(".sidebar-action-button").evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect()));
|
||||||
|
expect(storageActions[1].x).toBeGreaterThan(storageActions[0].x);
|
||||||
|
expect(storageActions[1].y).toBeCloseTo(storageActions[0].y, 0);
|
||||||
|
const secondaryBox = await secondaryLinks.boundingBox();
|
||||||
|
const storageBox = await page.locator(".sidebar-note").boundingBox();
|
||||||
|
expect(secondaryBox.y + secondaryBox.height).toBeLessThan(storageBox.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.goto("/#/about");
|
||||||
|
await expectFits(page.locator(".about-page"));
|
||||||
|
await expect(page.locator(".about-story-section")).toHaveCSS("display", "grid");
|
||||||
|
await columns(page.locator(".about-tools-list"), width <= 760 ? 1 : 2);
|
||||||
|
await expect(page.locator(".about-tool-item .module-icon").first()).toHaveCSS("width", "34px");
|
||||||
|
await page.goto("/#/library");
|
||||||
|
await expectFits(page.locator(".library-page"));
|
||||||
|
await columns(page.locator(".library-doc-layout"), width <= 1024 ? 1 : 2);
|
||||||
|
await columns(page.locator(".library-example-layout.has-legend").first(), width <= 1024 ? 1 : 2);
|
||||||
|
expect(await page.locator("body").evaluate((element) => element.scrollWidth)).toBeLessThanOrEqual(width);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [640, 390]) {
|
||||||
|
test(`below the validated width the compact sidebar remains available without phone navigation at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 720 });
|
||||||
|
await page.goto("/#/games/aniimo/catalog");
|
||||||
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
|
await expect(page.locator(".sidebar")).toHaveCSS("width", "72px");
|
||||||
|
await expect(page.locator(".mobile-nav")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".route-content-view")).toBeVisible();
|
||||||
|
await expect(page.locator(".compact-game-view-switch")).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [1280, 768]) {
|
||||||
|
test(`game grids and toolbox columns adapt without horizontal overflow at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await mountStyles(page, `<main>
|
||||||
|
<div class="game-layout"><aside class="game-filters"><div class="filter-panel nds-filter-panel">Filter</div></aside><div class="game-grid"><article>Game</article><article>Game</article></div></div>
|
||||||
|
<div class="modules" data-layout-cols="2"><div class="module-column"><article class="module">A</article></div><div class="module-column"><article class="module">B</article></div></div>
|
||||||
|
<article class="module layout-calculator" data-module-type="calculator"><div class="calculator-module"><div>Formula</div><div class="calculator-result"><strong>12</strong></div></div></article>
|
||||||
|
<div class="task-planner-module"><div class="task-planner-settings"><label>Reset</label><label>Time</label></div></div>
|
||||||
|
<div class="equipment-planner-summary"><div class="equipment-planner-summary-source-grid"><div>Equipment</div><div>Materials</div></div></div>
|
||||||
|
<div class="toolbox-actions-row"><div>Actions</div><div>Preferences</div></div>
|
||||||
|
</main>`);
|
||||||
|
await columns(page.locator(".game-layout"), 1);
|
||||||
|
await expect(page.locator(".game-grid")).toHaveCSS("display", "grid");
|
||||||
|
await columns(page.locator(".modules"), width <= 760 ? 1 : 2);
|
||||||
|
await expect(page.locator(".module-column").first()).toHaveCSS("display", width <= 760 ? "contents" : "grid");
|
||||||
|
await columns(page.locator(".layout-calculator .calculator-module"), width <= 820 ? 1 : 2);
|
||||||
|
await expect(page.locator(".calculator-result")).toHaveCSS("min-height", "72px");
|
||||||
|
await expect(page.locator(".task-planner-module")).toHaveCSS("display", "grid");
|
||||||
|
await columns(page.locator(".task-planner-settings"), width <= 760 ? 1 : 2);
|
||||||
|
await expect(page.locator(".equipment-planner-summary")).toHaveCSS("padding", "12px");
|
||||||
|
await columns(page.locator(".equipment-planner-summary-source-grid"), width <= 760 ? 1 : 2);
|
||||||
|
await page.locator(".equipment-planner-summary-source-grid").evaluate((element) => element.classList.add("columns-1"));
|
||||||
|
await columns(page.locator(".equipment-planner-summary-source-grid"), 1);
|
||||||
|
await expect(page.locator(".toolbox-actions-row")).toHaveCSS("flex-direction", width <= 760 ? "column" : "row");
|
||||||
|
await page.locator(".modules").evaluate((element) => { element.dataset.layoutCols = "1"; });
|
||||||
|
await columns(page.locator(".modules"), 1);
|
||||||
|
await expectFits(page.locator("main"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`cover crop and icon picker respect image geometry at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await mountStyles(page, `<main>
|
||||||
|
<section class="toolbox-cover-editor"><div class="toolbox-cover-crop-frame"></div></section>
|
||||||
|
<div class="toolbox-icon-picker"><button class="toolbox-icon-picker-button"><img alt="Icon"></button>
|
||||||
|
<div class="toolbox-icon-picker-menu"><button>A</button><button class="toolbox-cover-menu-action nds-button">Cover</button></div></div>
|
||||||
|
<div class="toolbox-game-icon"><img alt="Game"></div><a class="toolbox-hero-link">Game page</a>
|
||||||
|
<p class="dialogue-line"><i>Aside</i></p>
|
||||||
|
</main>`);
|
||||||
|
const frame = page.locator(".toolbox-cover-crop-frame");
|
||||||
|
await expectFits(frame);
|
||||||
|
const bounds = await frame.boundingBox();
|
||||||
|
expect(bounds.width / bounds.height).toBeCloseTo(620 / 248, 2);
|
||||||
|
await expect(frame).toHaveCSS("touch-action", "none");
|
||||||
|
await expect(frame).toHaveCSS("overflow", "hidden");
|
||||||
|
const icon = page.locator(".toolbox-icon-picker-button");
|
||||||
|
await expect(icon.locator("img")).toHaveCSS("object-fit", "contain");
|
||||||
|
await expect(icon.locator("img")).toHaveCSS("width", "72px");
|
||||||
|
expect(await computed(icon.locator("img"), "border-radius")).toBe(await computed(icon, "border-radius"));
|
||||||
|
await icon.evaluate((element) => element.classList.add("has-custom-cover"));
|
||||||
|
await expect(icon.locator("img")).toHaveCSS("object-fit", "cover");
|
||||||
|
await expect(page.locator(".toolbox-game-icon img")).toHaveCSS("object-fit", "cover");
|
||||||
|
await expect(page.locator(".toolbox-cover-menu-action")).toHaveCSS("grid-column", "1 / -1");
|
||||||
|
await expect(page.locator(".toolbox-icon-picker-menu")).toHaveCSS("position", "absolute");
|
||||||
|
await expect(page.locator(".toolbox-hero-link")).toHaveCSS("display", "flex");
|
||||||
|
await expect(page.locator(".dialogue-line i")).toHaveCSS("font-style", "italic");
|
||||||
|
});
|
||||||
|
}
|
||||||
123
e2e/table.spec.js
Normal file
123
e2e/table.spec.js
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// Rôle : vérifie les dimensions, sélections et échanges de cellules du tableau réel.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "table.json", mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify({
|
||||||
|
toolbox: { id: "table-fixture", name: "Table workflows", modules: [{ id: "grid", type: "table", title: "Tableau" }] },
|
||||||
|
modules: { grid: { rows: 3, columns: 3, cells: { A1: "2", B1: "3", C1: "=A1+B1", A2: "4", B2: "6", C3: "removed" } } },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Table workflows", exact: true }).first().click();
|
||||||
|
const table = page.locator('[data-module-type="table"]');
|
||||||
|
await expect(table.getByRole("grid", { name: "Tableau", exact: true })).toBeVisible();
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cell(table, address) {
|
||||||
|
return table.getByRole("textbox", { name: `Cellule ${address}`, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drag(page, from, to) {
|
||||||
|
await from.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await to.hover();
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("headers retain cell references and resizing removes only out-of-bounds data", async ({ page }) => {
|
||||||
|
const table = await setup(page);
|
||||||
|
const column = table.getByRole("textbox", { name: "Intitulé de colonne A", exact: true });
|
||||||
|
const row = table.getByRole("textbox", { name: "Intitulé de ligne 1", exact: true });
|
||||||
|
await column.click();
|
||||||
|
await expect.poll(() => column.evaluate((input) => [input.selectionStart, input.selectionEnd])).toEqual([0, 1]);
|
||||||
|
await column.fill("Quantity");
|
||||||
|
await column.press("Enter");
|
||||||
|
await row.fill("Materials");
|
||||||
|
await row.press("Enter");
|
||||||
|
await column.fill("Cancelled");
|
||||||
|
await column.press("Escape");
|
||||||
|
await expect(column).toHaveValue("Quantity");
|
||||||
|
await expect(cell(table, "C1")).toHaveValue("5");
|
||||||
|
await page.reload();
|
||||||
|
await expect(column).toHaveValue("Quantity");
|
||||||
|
await expect(row).toHaveValue("Materials");
|
||||||
|
await table.getByRole("button", { name: "Supprimer la dernière ligne", exact: true }).click();
|
||||||
|
await table.getByRole("button", { name: "Supprimer la dernière colonne", exact: true }).click();
|
||||||
|
await expect(table.getByRole("textbox", { name: /^Cellule / })).toHaveCount(4);
|
||||||
|
await table.getByRole("button", { name: "Ajouter une ligne", exact: true }).click();
|
||||||
|
await table.getByRole("button", { name: "Ajouter une colonne", exact: true }).click();
|
||||||
|
await expect(cell(table, "C1")).toHaveValue("");
|
||||||
|
await expect(cell(table, "C3")).toHaveValue("");
|
||||||
|
await expect(cell(table, "A1")).toHaveValue("2");
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
await table.getByRole("button", { name: "Supprimer la dernière ligne", exact: true }).click();
|
||||||
|
await table.getByRole("button", { name: "Supprimer la dernière colonne", exact: true }).click();
|
||||||
|
}
|
||||||
|
await expect(table.getByRole("button", { name: "Supprimer la dernière ligne", exact: true })).toBeDisabled();
|
||||||
|
await expect(table.getByRole("button", { name: "Supprimer la dernière colonne", exact: true })).toBeDisabled();
|
||||||
|
await page.reload();
|
||||||
|
await expect(table.getByRole("textbox", { name: /^Cellule / })).toHaveCount(1);
|
||||||
|
await expect(cell(table, "A1")).toHaveValue("2");
|
||||||
|
await expect(column).toHaveValue("Quantity");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyboard selection copies evaluated TSV, pastes blocks and clears only selected cells", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
const table = await setup(page);
|
||||||
|
await cell(table, "A1").click();
|
||||||
|
await cell(table, "A1").press("ArrowRight");
|
||||||
|
await expect(cell(table, "B1")).toBeFocused();
|
||||||
|
await cell(table, "B1").press("ArrowDown");
|
||||||
|
await expect(cell(table, "B2")).toBeFocused();
|
||||||
|
await cell(table, "B2").press("ArrowLeft");
|
||||||
|
await expect(cell(table, "A2")).toBeFocused();
|
||||||
|
await cell(table, "A2").press("ArrowUp");
|
||||||
|
await expect(cell(table, "A1")).toBeFocused();
|
||||||
|
await cell(table, "A1").press("Shift+ArrowRight");
|
||||||
|
await cell(table, "B1").press("Shift+ArrowRight");
|
||||||
|
await cell(table, "C1").press("ControlOrMeta+c");
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("2\t3\t5");
|
||||||
|
await cell(table, "A3").click();
|
||||||
|
await cell(table, "A3").press("ControlOrMeta+v");
|
||||||
|
await expect(cell(table, "A3")).toHaveValue("2");
|
||||||
|
await expect(cell(table, "B3")).toHaveValue("3");
|
||||||
|
await expect(cell(table, "C3")).toHaveValue("5");
|
||||||
|
await cell(table, "A3").press("Delete");
|
||||||
|
await expect(cell(table, "A3")).toHaveValue("");
|
||||||
|
await expect(cell(table, "B3")).toHaveValue("");
|
||||||
|
await expect(cell(table, "C3")).toHaveValue("");
|
||||||
|
await cell(table, "A1").click();
|
||||||
|
await table.getByRole("button", { name: "Copier les valeurs", exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("2\t3\t5\n4\t6\t\n\t\t");
|
||||||
|
await page.reload();
|
||||||
|
await expect(cell(table, "C1")).toHaveValue("5");
|
||||||
|
await expect(cell(table, "C3")).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pointer selections move blocks and insert ranges into formulas without changing source values", async ({ page }) => {
|
||||||
|
const table = await setup(page);
|
||||||
|
await drag(page, cell(table, "A2"), cell(table, "B2"));
|
||||||
|
await expect(table.locator(".table-cell-input.is-selected")).toHaveCount(2);
|
||||||
|
await drag(page, cell(table, "A2"), cell(table, "A3"));
|
||||||
|
await expect(cell(table, "A2")).toHaveValue("");
|
||||||
|
await expect(cell(table, "B2")).toHaveValue("");
|
||||||
|
await expect(cell(table, "A3")).toHaveValue("4");
|
||||||
|
await expect(cell(table, "B3")).toHaveValue("6");
|
||||||
|
await cell(table, "C2").dblclick();
|
||||||
|
await cell(table, "C2").fill("=");
|
||||||
|
await drag(page, cell(table, "A1"), cell(table, "B1"));
|
||||||
|
await expect(cell(table, "C2")).toHaveValue("=(A1+B1)");
|
||||||
|
await cell(table, "C2").press("Enter");
|
||||||
|
await expect(cell(table, "C2")).toHaveValue("5");
|
||||||
|
await expect(cell(table, "A1")).toHaveValue("2");
|
||||||
|
await expect(cell(table, "B1")).toHaveValue("3");
|
||||||
|
await page.reload();
|
||||||
|
await expect(cell(table, "C2")).toHaveValue("5");
|
||||||
|
await expect(cell(table, "A2")).toHaveValue("");
|
||||||
|
await expect(cell(table, "A3")).toHaveValue("4");
|
||||||
|
await expect(cell(table, "B3")).toHaveValue("6");
|
||||||
|
});
|
||||||
133
e2e/task-planner.spec.js
Normal file
133
e2e/task-planner.spec.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// Rôle : vérifie l'édition, les prérequis et les réinitialisations du planificateur réel.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "tasks.json", mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify({
|
||||||
|
toolbox: { id: "tasks", name: "Task workflows", modules: [{ id: "planner", type: "taskPlanner", title: "Tasks" }] },
|
||||||
|
modules: { planner: { resetTime: "00:00", weeklyResetDay: 1, tasks: [
|
||||||
|
{ id: "parent", title: "Parent", type: "unique", category: "Raid" },
|
||||||
|
{ id: "child", title: "Child", type: "daily" },
|
||||||
|
{ id: "weekly", title: "Weekly", type: "weekly" },
|
||||||
|
] } },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Task workflows", exact: true }).first().click();
|
||||||
|
const tool = page.locator('[data-module-type="taskPlanner"]');
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer effectué Parent", exact: true })).toBeVisible();
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("creates and edits a categorized task with a multiline description and persists deletion", async ({ page }) => {
|
||||||
|
const tool = await setup(page);
|
||||||
|
await tool.getByRole("button", { name: "Afficher l'ajout de Tasks", exact: true }).click();
|
||||||
|
const form = tool.locator(".task-planner-add-form");
|
||||||
|
await form.getByRole("button", { name: "Ajouter", exact: true }).click();
|
||||||
|
await expect(tool.locator(".task-planner-title-input")).toHaveCount(3);
|
||||||
|
await form.getByPlaceholder("Nouvelle tâche", { exact: true }).fill("New task");
|
||||||
|
await form.getByPlaceholder("Nom de catégorie", { exact: true }).fill("Crafting");
|
||||||
|
await form.getByRole("combobox", { name: "Type", exact: true }).selectOption("unique");
|
||||||
|
await form.getByRole("button", { name: "Ajouter", exact: true }).click();
|
||||||
|
await expect(form.getByPlaceholder("Nouvelle tâche", { exact: true })).toHaveValue("");
|
||||||
|
await expect(tool.getByRole("textbox", { name: "Titre", exact: true })).toHaveCount(4);
|
||||||
|
const item = tool.locator("li.task-planner-item").filter({ has: page.getByRole("button", { name: "Supprimer New task", exact: true }) });
|
||||||
|
await item.getByRole("textbox", { name: "Titre", exact: true }).fill("Renamed");
|
||||||
|
await item.getByRole("textbox", { name: "Titre", exact: true }).press("Enter");
|
||||||
|
await tool.getByRole("button", { name: "Afficher la description Renamed", exact: true }).click();
|
||||||
|
const description = tool.getByRole("textbox", { name: "Description Renamed", exact: true });
|
||||||
|
await description.fill("First line");
|
||||||
|
await description.press("End");
|
||||||
|
await description.press("Enter");
|
||||||
|
await description.pressSequentially("Second line");
|
||||||
|
await tool.getByRole("textbox", { name: "Catégorie Renamed", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await tool.getByRole("button", { name: "Afficher la description Renamed", exact: true }).click();
|
||||||
|
await expect(description).toHaveValue("First line\nSecond line");
|
||||||
|
await expect(tool.getByRole("textbox", { name: "Catégorie Renamed", exact: true })).toHaveValue("Crafting");
|
||||||
|
await tool.getByRole("button", { name: "Supprimer Renamed", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(tool.locator(".task-planner-title-input")).toHaveCount(3);
|
||||||
|
await expect(tool.getByRole("button", { name: "Supprimer Renamed", exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parent links inherit categories, warn without blocking completion and are removed when the parent is deleted", async ({ page }) => {
|
||||||
|
const tool = await setup(page);
|
||||||
|
await tool.getByRole("button", { name: "Réglages de la tâche Child", exact: true }).click();
|
||||||
|
await tool.getByRole("combobox", { name: "Définir un parent", exact: true }).selectOption({ label: "Parent" });
|
||||||
|
await tool.getByRole("checkbox", { name: "Pré requis Parent", exact: true }).check();
|
||||||
|
await tool.getByRole("button", { name: "Afficher la description Child", exact: true }).click();
|
||||||
|
const category = tool.getByRole("textbox", { name: "Catégorie Child", exact: true });
|
||||||
|
await expect(category).toBeDisabled();
|
||||||
|
await expect(category).toHaveValue("Raid");
|
||||||
|
await tool.getByRole("checkbox", { name: "Marquer effectué Child", exact: true }).check();
|
||||||
|
await expect(tool.getByText("Pré requis parent non effectué", { exact: true })).toBeVisible();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer non effectué Child", exact: true })).toBeChecked();
|
||||||
|
await tool.getByRole("checkbox", { name: "Marquer effectué Parent", exact: true }).check();
|
||||||
|
await expect(tool.locator(".task-planner-warning")).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await tool.getByRole("button", { name: "Réglages de la tâche Child", exact: true }).click();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Pré requis Parent", exact: true })).toBeChecked();
|
||||||
|
await tool.getByRole("button", { name: "Supprimer Parent", exact: true }).click();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Pré requis Parent", exact: true })).toHaveCount(0);
|
||||||
|
await expect(tool.getByText("Aucun parent", { exact: true })).toBeVisible();
|
||||||
|
await tool.getByRole("button", { name: "Afficher la description Child", exact: true }).click();
|
||||||
|
await expect(category).toBeEnabled();
|
||||||
|
await page.reload();
|
||||||
|
await expect(tool.getByRole("button", { name: "Supprimer Child", exact: true })).toBeVisible();
|
||||||
|
await expect(tool.getByRole("button", { name: "Supprimer Parent", exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recurring resets preserve unique completions and cleanup deletes only completed unique tasks", async ({ page }) => {
|
||||||
|
await page.clock.install({ time: new Date("2030-01-07T12:00:00Z") });
|
||||||
|
const tool = await setup(page);
|
||||||
|
for (const title of ["Parent", "Child", "Weekly"]) {
|
||||||
|
await tool.getByRole("checkbox", { name: `Marquer effectué ${title}`, exact: true }).check();
|
||||||
|
}
|
||||||
|
await page.clock.fastForward(8 * 24 * 60 * 60 * 1000);
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer effectué Child", exact: true })).not.toBeChecked();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer effectué Weekly", exact: true })).not.toBeChecked();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer non effectué Parent", exact: true })).toBeChecked();
|
||||||
|
const hide = tool.getByRole("button", { name: "Masquer les tâches effectuées", exact: true });
|
||||||
|
await hide.click();
|
||||||
|
await expect(tool.getByRole("button", { name: "Supprimer Parent", exact: true })).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(hide).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await hide.click();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer non effectué Parent", exact: true })).toBeChecked();
|
||||||
|
await tool.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
const cleanup = tool.getByRole("button", { name: "Nettoyer les tâches ponctuelles effectuées", exact: true });
|
||||||
|
await cleanup.click();
|
||||||
|
await expect(cleanup).toBeDisabled();
|
||||||
|
await page.reload();
|
||||||
|
await expect(tool.locator(".task-planner-title-input")).toHaveCount(2);
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer effectué Child", exact: true })).toBeVisible();
|
||||||
|
await expect(tool.getByRole("checkbox", { name: "Marquer effectué Weekly", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reordering a parent moves its subtree and rejects detaching a child", async ({ page }) => {
|
||||||
|
const tool = await setup(page);
|
||||||
|
await tool.getByRole("button", { name: "Réglages de la tâche Child", exact: true }).click();
|
||||||
|
await tool.getByRole("combobox", { name: "Définir un parent", exact: true }).selectOption({ label: "Parent" });
|
||||||
|
await tool.getByRole("button", { name: "Réglages de la tâche Child", exact: true }).click();
|
||||||
|
async function drag(source, target) {
|
||||||
|
await tool.getByRole("button", { name: `Déplacer ${source}`, exact: true }).hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
const box = await tool.getByRole("button", { name: `Déplacer ${target}`, exact: true }).boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height * 0.2, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
const titles = () => tool.locator(".task-planner-title-input").evaluateAll((inputs) => inputs.map((input) => input.value));
|
||||||
|
await drag("Weekly", "Parent");
|
||||||
|
await expect.poll(titles).toEqual(["Weekly", "Parent", "Child"]);
|
||||||
|
await drag("Parent", "Weekly");
|
||||||
|
await expect.poll(titles).toEqual(["Parent", "Child", "Weekly"]);
|
||||||
|
await drag("Child", "Weekly");
|
||||||
|
await expect.poll(titles).toEqual(["Parent", "Child", "Weekly"]);
|
||||||
|
await page.reload();
|
||||||
|
await expect.poll(titles).toEqual(["Parent", "Child", "Weekly"]);
|
||||||
|
await tool.getByRole("button", { name: "Réglages de la tâche Child", exact: true }).click();
|
||||||
|
await expect(tool.getByRole("button", { name: "Retirer Parent", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
107
e2e/text-exchange.spec.js
Normal file
107
e2e/text-exchange.spec.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Rôle : vérifie les modales d'échange texte, les erreurs et la persistance des imports réels.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "exchange.json", mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify({
|
||||||
|
toolbox: { id: "exchange", name: "Text exchange", modules: [
|
||||||
|
{ id: "table", type: "table", title: "Tableau" },
|
||||||
|
{ id: "calc", type: "calculator", title: "Calculateur" },
|
||||||
|
] },
|
||||||
|
modules: { table: { rows: 2, columns: 2, cells: {} }, calc: { entries: [{ id: "original", label: "Original", value: 7, formula: "7" }] } },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Text exchange", exact: true }).first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("table text import/export preserves formulas and labels, copies read-only text and resets cancelled drafts", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await setup(page);
|
||||||
|
const table = page.locator('[data-module-type="table"]');
|
||||||
|
const importButton = table.getByRole("button", { name: "Importer TSV", exact: true });
|
||||||
|
await expect(importButton).toBeHidden();
|
||||||
|
await table.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
await expect(table.getByRole("button", { name: "Exporter TSV", exact: true })).toBeDisabled();
|
||||||
|
const originalOverflow = await page.locator("body").evaluate((body) => body.style.overflow);
|
||||||
|
await importButton.click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Importer un tableau TSV", exact: true });
|
||||||
|
await expect(dialog.getByRole("textbox")).toBeFocused();
|
||||||
|
await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
|
||||||
|
await dialog.getByRole("textbox").fill("cancelled");
|
||||||
|
await dialog.getByRole("button", { name: "Annuler", exact: true }).click();
|
||||||
|
await expect.poll(() => page.locator("body").evaluate((body) => body.style.overflow)).toBe(originalOverflow);
|
||||||
|
await importButton.click();
|
||||||
|
await expect(dialog.getByRole("textbox")).toHaveValue("");
|
||||||
|
await dialog.getByRole("button", { name: "Importer le TSV", exact: true }).click();
|
||||||
|
await expect(dialog.getByRole("alert")).toContainText("Le texte à importer est vide.");
|
||||||
|
await expect(dialog.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
|
||||||
|
const text = "@size: 2x2\n@columns: Quantity\tTotal\n@rows: Ore\tBars\n@tsv\n3\t=A1*2\n5\t=A2*2";
|
||||||
|
await dialog.getByRole("textbox").fill(text);
|
||||||
|
await expect(dialog.getByRole("alert")).toHaveCount(0);
|
||||||
|
await expect(dialog.getByRole("textbox")).toHaveAttribute("aria-invalid", "false");
|
||||||
|
await dialog.getByRole("button", { name: "Importer le TSV", exact: true }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(table.getByRole("textbox", { name: "Cellule B2", exact: true })).toHaveValue("10");
|
||||||
|
await page.reload();
|
||||||
|
await expect(table.getByRole("textbox", { name: "Intitulé de colonne A", exact: true })).toHaveValue("Quantity");
|
||||||
|
await expect(table.getByRole("textbox", { name: "Intitulé de ligne 1", exact: true })).toHaveValue("Ore");
|
||||||
|
await expect(table.getByRole("textbox", { name: "Cellule B1", exact: true })).toHaveValue("6");
|
||||||
|
await table.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
await table.getByRole("button", { name: "Exporter TSV", exact: true }).click();
|
||||||
|
const exported = page.getByRole("dialog", { name: "Exporter un tableau TSV", exact: true });
|
||||||
|
await expect(exported.getByRole("textbox")).toHaveAttribute("readonly", "");
|
||||||
|
await expect(exported.getByRole("textbox")).toHaveValue(text);
|
||||||
|
await exported.getByRole("button", { name: "Copier le TSV", exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe(text);
|
||||||
|
await expect(exported).toBeVisible();
|
||||||
|
await exported.getByRole("textbox").press("Escape");
|
||||||
|
await expect(exported).toBeHidden();
|
||||||
|
await expect.poll(() => page.locator("body").evaluate((body) => body.style.overflow)).toBe(originalOverflow);
|
||||||
|
await table.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
await expect(importButton).toBeHidden();
|
||||||
|
await expect(table.getByRole("button", { name: "Configuration", exact: true })).toHaveAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid calculator imports preserve existing results and a corrected import replaces them with a persistent hierarchy", async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
const calc = page.locator('[data-module-type="calculator"]');
|
||||||
|
await calc.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
const openImport = calc.getByRole("button", { name: "Importer du texte", exact: true });
|
||||||
|
await openImport.click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Importer des résultats", exact: true });
|
||||||
|
await dialog.getByRole("textbox").fill("invalid");
|
||||||
|
await dialog.getByRole("button", { name: "Importer le texte", exact: true }).click();
|
||||||
|
await expect(dialog.getByRole("alert")).toContainText("ligne calculateur invalide");
|
||||||
|
await expect(calc.locator(".calculator-entry em")).toHaveText(["Original"]);
|
||||||
|
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(calc.locator(".calculator-entry em")).toHaveText(["Original"]);
|
||||||
|
await calc.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
await openImport.click();
|
||||||
|
await expect(dialog.getByRole("textbox")).toHaveValue("");
|
||||||
|
await expect(dialog.getByRole("alert")).toHaveCount(0);
|
||||||
|
const text = "@scrollResults: true\nOre: 10 | formula=10\n Bars: 20 | formula=base*2";
|
||||||
|
await dialog.getByRole("textbox").fill(text);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
globalThis.__sokkogModuleWriteComplete = false;
|
||||||
|
globalThis.addEventListener("sokkog:auto-backup-data-changed", () => {
|
||||||
|
globalThis.__sokkogModuleWriteComplete = true;
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
await dialog.getByRole("button", { name: "Importer le texte", exact: true }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(calc.locator(".calculator-entry em")).toHaveText(["Ore", "Bars"]);
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.__sokkogModuleWriteComplete)).toBe(true);
|
||||||
|
await page.reload();
|
||||||
|
await expect(calc.locator(".calculator-entry em")).toHaveText(["Ore", "Bars"]);
|
||||||
|
await expect(calc.getByRole("button", { name: "Liste scrollable", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await calc.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
await calc.getByRole("button", { name: "Exporter en format texte", exact: true }).click();
|
||||||
|
const exported = page.getByRole("dialog", { name: "Exporter des résultats", exact: true });
|
||||||
|
await expect(exported.getByRole("textbox")).toHaveValue(text);
|
||||||
|
await page.locator(".text-import-modal-root .confirm-backdrop").click({ position: { x: 10, y: 10 } });
|
||||||
|
await expect(exported).toBeHidden();
|
||||||
|
});
|
||||||
135
e2e/tool-style-states.spec.js
Normal file
135
e2e/tool-style-states.spec.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// Rôle : vérifie les états visuels des outils, leurs pickers et les masques d'icônes compilés.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { mountStyles, computed, expectPseudo, expectFits } from "./fixtures/compiled-styles.js";
|
||||||
|
|
||||||
|
test("table cells distinguish selection, move targets, editing and errors", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<div class="table-module"><div class="table-grid-wrap"><input type="text" class="table-cell-input" value="12"></div></div>`);
|
||||||
|
await expect(page.locator(".table-module")).toHaveCSS("display", "grid");
|
||||||
|
const cell = page.locator("input");
|
||||||
|
await expect(cell).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)");
|
||||||
|
await cell.evaluate((element) => element.classList.add("is-selected"));
|
||||||
|
await expect(cell).toHaveCSS("box-shadow", /34, 211, 238/);
|
||||||
|
await cell.evaluate((element) => { element.className = "table-cell-input is-move-target"; });
|
||||||
|
await expect(cell).toHaveCSS("box-shadow", /246, 196, 83/);
|
||||||
|
await expect(cell).toHaveCSS("cursor", "move");
|
||||||
|
await cell.evaluate((element) => { element.className = "table-cell-input is-editing"; });
|
||||||
|
await cell.focus();
|
||||||
|
await expect(cell).not.toHaveCSS("caret-color", "rgba(0, 0, 0, 0)");
|
||||||
|
await cell.evaluate((element) => { element.blur(); element.className = "table-cell-input has-error"; });
|
||||||
|
await expect(cell).toHaveCSS("color", "rgb(254, 202, 202)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("goal nodes and NDS reorder indicators follow orientation and placement", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<main>
|
||||||
|
<div class="goal-tree-node is-drop-target" data-reorder-orientation="vertical"><div class="goal-tree-node-card"><div class="goal-tree-node-content-row"><span>1</span><span>Goal</span></div></div></div>
|
||||||
|
<div class="nds-reorder-indicator" style="width:200px;height:60px"></div>
|
||||||
|
</main>`);
|
||||||
|
const node = page.locator(".goal-tree-node");
|
||||||
|
const card = page.locator(".goal-tree-node-card");
|
||||||
|
const indicator = page.locator(".nds-reorder-indicator");
|
||||||
|
await expect(card).toHaveCSS("border-top-color", "rgba(246, 196, 83, 0.48)");
|
||||||
|
await expect(page.locator(".goal-tree-node-content-row")).toHaveCSS("display", "grid");
|
||||||
|
for (const axis of ["vertical", "horizontal"]) {
|
||||||
|
for (const placement of ["before", "after"]) {
|
||||||
|
await node.evaluate((element, { axis, placement }) => { element.dataset.reorderOrientation = axis; element.classList.toggle("drop-after", placement === "after"); }, { axis, placement });
|
||||||
|
await indicator.evaluate((element, { axis, placement }) => { element.dataset.axis = axis; element.dataset.placement = placement; }, { axis, placement });
|
||||||
|
const side = axis === "vertical" ? (placement === "before" ? "top" : "bottom") : (placement === "before" ? "left" : "right");
|
||||||
|
const thickness = axis === "vertical" ? "height" : "width";
|
||||||
|
await expectPseudo(card, "::after", side, "-8px");
|
||||||
|
await expectPseudo(card, "::after", thickness, "3px");
|
||||||
|
await expectPseudo(indicator, "::before", side, "0px");
|
||||||
|
await expectPseudo(indicator, "::before", thickness, "3px");
|
||||||
|
await expectPseudo(card, "::after", "pointer-events", "none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calendar picker opens on focus and alert controls retain blocked states", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<main><form class="calendar-event-form">
|
||||||
|
<div class="calendar-month-date-dropdown"><button type="button" class="calendar-month-date-trigger">Date</button><div class="calendar-month-date-picker"><button type="button">1</button></div></div>
|
||||||
|
<div class="timer-alert-mode-switch calendar-alert-mode-switch"><button type="button" class="timer-alert-mode-button">Alert</button></div>
|
||||||
|
<button type="button" class="tool-split-scroll-toggle"><span class="ui-icon"></span><i></i></button>
|
||||||
|
</form></main>`);
|
||||||
|
await expect(page.locator("form")).toHaveCSS("display", "grid");
|
||||||
|
await expect(page.locator("form")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||||
|
const picker = page.locator(".calendar-month-date-picker");
|
||||||
|
await expect(picker).toHaveCSS("opacity", "0");
|
||||||
|
await expect(picker).toHaveCSS("pointer-events", "none");
|
||||||
|
await page.locator(".calendar-month-date-trigger").focus();
|
||||||
|
await expect(picker).toHaveCSS("opacity", "1");
|
||||||
|
await expect(picker).toHaveCSS("pointer-events", "auto");
|
||||||
|
expect((await computed(picker, "grid-template-columns")).split(" ")).toHaveLength(7);
|
||||||
|
const alert = page.locator(".timer-alert-mode-switch");
|
||||||
|
await expect(alert).toHaveCSS("align-self", "start");
|
||||||
|
await alert.evaluate((element) => element.classList.add("is-blocked"));
|
||||||
|
await expect(alert.locator("button")).toHaveCSS("opacity", "0.2");
|
||||||
|
await expect(alert.locator("button")).toHaveCSS("cursor", "not-allowed");
|
||||||
|
const scroll = page.locator(".tool-split-scroll-toggle");
|
||||||
|
await scroll.focus();
|
||||||
|
await expect(picker).toHaveCSS("opacity", "0");
|
||||||
|
await expectPseudo(scroll.locator("i"), "::before", "left", "3px");
|
||||||
|
await scroll.evaluate((element) => element.classList.add("active"));
|
||||||
|
await expectPseudo(scroll.locator("i"), "::before", "left", "17px");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("combo tokens distinguish analog, directional and held inputs", async ({ page }) => {
|
||||||
|
await mountStyles(page, `<main>
|
||||||
|
<div class="combo-hold-field"><div class="combo-hold-controls"><button class="active">Hold</button><input type="number" value="1"></div></div>
|
||||||
|
<span id="analog" class="combo-input-token combo-input-direction combo-device-n64 combo-value-stick-up">Up</span>
|
||||||
|
<span id="direction" class="combo-input-token combo-input-direction combo-device-n64 combo-value-up">Up</span>
|
||||||
|
<span class="combo-input-token combo-value-grab has-hold"><i class="ui-icon"></i></span>
|
||||||
|
</main>`);
|
||||||
|
await expect(page.locator(".combo-hold-controls")).toHaveCSS("width", "92px");
|
||||||
|
await expect(page.locator(".combo-hold-controls button")).toHaveCSS("background-image", /246, 196, 83/);
|
||||||
|
await expect(page.locator("#analog")).toHaveCSS("color", "rgb(165, 243, 252)");
|
||||||
|
await expect(page.locator("#direction")).toHaveCSS("color", "rgb(248, 217, 130)");
|
||||||
|
await expect(page.locator("#direction")).toHaveCSS("border-radius", "10px");
|
||||||
|
await expect(page.locator(".combo-value-grab .ui-icon")).toHaveCSS("width", "24px");
|
||||||
|
await expect(page.locator(".has-hold")).toHaveCSS("box-shadow", /7px/);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [1280, 390]) {
|
||||||
|
test(`text exchange and image annotation keep usable bounds at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await mountStyles(page, `<main>
|
||||||
|
<div class="text-import-modal-dialog nds-modal"><form class="text-import-modal-form"><textarea class="has-error"></textarea><footer class="text-import-actions"><button>Import</button><button>Export</button></footer></form></div>
|
||||||
|
<div id="image" style="position:relative;width:300px;height:200px"><button class="annotation-marker">1</button></div>
|
||||||
|
</main>`);
|
||||||
|
await expectFits(page.locator(".text-import-modal-dialog"));
|
||||||
|
await expect(page.locator(".text-import-actions")).toHaveCSS("flex-wrap", "wrap");
|
||||||
|
await expect(page.locator("textarea")).toHaveCSS("border-top-color", "rgba(248, 113, 113, 0.75)");
|
||||||
|
const marker = page.locator(".annotation-marker");
|
||||||
|
for (const position of ["0%", "50%", "100%"]) {
|
||||||
|
await marker.evaluate((element, position) => { element.style.setProperty("--marker-x", position); element.style.setProperty("--marker-y", position); }, position);
|
||||||
|
const box = await marker.boundingBox();
|
||||||
|
const frame = await page.locator("#image").boundingBox();
|
||||||
|
expect(box.width).toBe(30);
|
||||||
|
expect(box.height).toBe(30);
|
||||||
|
expect(box.x).toBeGreaterThanOrEqual(frame.x);
|
||||||
|
expect(box.y).toBeGreaterThanOrEqual(frame.y);
|
||||||
|
expect(box.x + box.width).toBeLessThanOrEqual(frame.x + frame.width);
|
||||||
|
expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("public icon masks resolve to decodable assets instead of empty shapes", async ({ page }) => {
|
||||||
|
const icons = ["drag", "dropdown", "enter", "calculator", "scrollable", "hide", "eye-open", "eye-closed", "chevron-down", "chevron-up", "flip", "add", "add-row", "add-column", "remove-row", "remove-column", "table", "map", "grab", "shield", "gun"];
|
||||||
|
const modules = ["abacus", "calculator", "columns", "table", "chest-armor"];
|
||||||
|
await page.route("**/__style-icons", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><html><body></body></html>" }));
|
||||||
|
await page.goto("/__style-icons");
|
||||||
|
await mountStyles(page, `<main>${icons.map((name) => `<i class="ui-icon ui-icon-${name}"></i>`).join("")}${modules.map((name) => `<i class="module-icon-svg module-icon-${name}"></i>`).join("")}</main>`);
|
||||||
|
for (const icon of await page.locator("i").all()) {
|
||||||
|
await expect(icon).toHaveCSS("mask-size", "contain");
|
||||||
|
const mask = await computed(icon, "mask-image");
|
||||||
|
expect(mask).toMatch(/^url\(".+"\)$/);
|
||||||
|
const url = JSON.parse(mask.slice(4, -1));
|
||||||
|
const decoded = await icon.evaluate(async (element, url) => {
|
||||||
|
const image = new element.ownerDocument.defaultView.Image();
|
||||||
|
image.src = url;
|
||||||
|
await image.decode();
|
||||||
|
return image.naturalWidth > 0 && image.naturalHeight > 0;
|
||||||
|
}, url);
|
||||||
|
expect(decoded, url).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
124
e2e/toolbox-cover.spec.js
Normal file
124
e2e/toolbox-cover.spec.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// Rôle : vérifie la persistance et le remplacement des images de toolbox.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const imagePath = fileURLToPath(new URL("../website/public/static/img/toolbox-icons/sword.png", import.meta.url));
|
||||||
|
const fixture = JSON.parse(await readFile(new URL("./fixtures/toolbox-export.json", import.meta.url), "utf8"));
|
||||||
|
const cover = `data:image/png;base64,${(await readFile(imagePath)).toString("base64")}`;
|
||||||
|
const { games } = JSON.parse(await readFile(new URL("../website/public/data/games.json", import.meta.url), "utf8"));
|
||||||
|
const gameCover = games.find((game) => game.id === "mhwilds").images.cardCover;
|
||||||
|
|
||||||
|
function picker(page) {
|
||||||
|
return page.getByRole("button", { name: "Changer l'image de la toolbox" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importToolbox(page, name, scope = page.getByText("Importer une toolbox", { exact: true })) {
|
||||||
|
await scope.locator('input[type="file"][accept="application/json"]').setInputFiles({
|
||||||
|
name: "cover.json", mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify({ ...fixture, toolbox: { ...fixture.toolbox, name, customCover: cover } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadCover(page, file = imagePath) {
|
||||||
|
await picker(page).click();
|
||||||
|
const action = page.getByRole("button", { name: /Ajouter une image de card|Remplacer l'image de card/ });
|
||||||
|
await expect(action).toBeVisible();
|
||||||
|
const chooser = page.waitForEvent("filechooser");
|
||||||
|
await action.click();
|
||||||
|
await (await chooser).setFiles(file);
|
||||||
|
return page.getByRole("dialog", { name: "Image de card", exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportToolbox(page, name) {
|
||||||
|
const download = page.waitForEvent("download");
|
||||||
|
await page.getByRole("button", { name: `Exporter ${name}`, exact: true }).click();
|
||||||
|
return JSON.parse(await readFile(await (await download).path(), "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("saves a cropped cover through reload and export/import, then returns to an icon", async ({ page }) => {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await page.getByRole("button", { name: "Nouvelle toolbox" }).click();
|
||||||
|
const creation = page.getByRole("dialog", { name: "Nouvelle toolbox" });
|
||||||
|
await creation.getByLabel("Nom").fill("Cover workflow");
|
||||||
|
await creation.getByRole("button", { name: "Créer" }).click();
|
||||||
|
await expect(picker(page).locator("img")).toHaveAttribute("src", /toolbox\.png$/);
|
||||||
|
await picker(page).click();
|
||||||
|
await page.getByRole("button", { name: "Utiliser l'icône sword", exact: true }).click();
|
||||||
|
const editor = await uploadCover(page);
|
||||||
|
await expect(editor.getByRole("button", { name: "Valider" })).toBeEnabled();
|
||||||
|
await editor.getByRole("button", { name: "Valider" }).click();
|
||||||
|
await expect(editor).toBeHidden();
|
||||||
|
const savedCover = await picker(page).locator("img").getAttribute("src");
|
||||||
|
expect(savedCover).toMatch(/^data:image\/(webp|jpeg);base64,/);
|
||||||
|
await expect.poll(() => picker(page).locator("img").evaluate((img) => [img.naturalWidth, img.naturalHeight])).toEqual([620, 248]);
|
||||||
|
await page.reload();
|
||||||
|
await expect(picker(page).locator("img")).toHaveAttribute("src", savedCover);
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(page.getByRole("link", { name: "Ouvrir Cover workflow", exact: true }).first().locator("img")).toHaveAttribute("src", savedCover);
|
||||||
|
const exported = await exportToolbox(page, "Cover workflow");
|
||||||
|
expect(exported.toolbox.customCover).toBe(savedCover);
|
||||||
|
expect(exported.toolbox.icon).toMatch(/sword\.png$/);
|
||||||
|
exported.toolbox.name = "Imported cover";
|
||||||
|
await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({
|
||||||
|
name: "export.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(exported)),
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Imported cover", exact: true }).first().click();
|
||||||
|
await expect(picker(page).locator("img")).toHaveAttribute("src", savedCover);
|
||||||
|
await picker(page).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Remplacer l'image de card" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.getByRole("button", { name: "Utiliser l'icône sword", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await page.getByRole("button", { name: "Utiliser l'icône sword", exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(picker(page).locator("img")).toHaveAttribute("src", /sword\.png$/);
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
expect((await exportToolbox(page, "Imported cover")).toolbox.customCover || "").toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelled and unreadable replacements preserve the cover and allow selecting the same file again", async ({ page }) => {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await importToolbox(page, "Preserved cover");
|
||||||
|
await page.getByRole("link", { name: "Ouvrir Preserved cover", exact: true }).first().click();
|
||||||
|
let editor = await uploadCover(page);
|
||||||
|
await expect(editor.getByRole("button", { name: "Valider" })).toBeEnabled();
|
||||||
|
await editor.getByRole("button", { name: "Annuler" }).click();
|
||||||
|
await expect(picker(page).locator("img")).toHaveAttribute("src", cover);
|
||||||
|
editor = await uploadCover(page);
|
||||||
|
await expect(editor.getByRole("button", { name: "Valider" })).toBeEnabled();
|
||||||
|
await editor.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
editor = await uploadCover(page, { name: "broken.png", mimeType: "image/png", buffer: Buffer.from("not an image") });
|
||||||
|
await expect(editor.getByText("Image illisible. Choisis un autre fichier.")).toBeVisible();
|
||||||
|
await expect(editor.getByRole("button", { name: "Valider" })).toBeDisabled();
|
||||||
|
await editor.getByRole("button", { name: "Annuler" }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(picker(page).locator("img")).toHaveAttribute("src", cover);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("game association takes precedence without discarding the custom cover", async ({ page }) => {
|
||||||
|
await page.goto("/#/games/mhwilds");
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
const drawer = page.locator("#toolbox-drawer .drawer-panel");
|
||||||
|
await importToolbox(page, "Linked cover", drawer);
|
||||||
|
await expect(drawer.getByRole("heading", { name: "Linked cover" })).toBeVisible();
|
||||||
|
await drawer.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
const link = page.getByRole("link", { name: "Ouvrir Linked cover", exact: true }).first();
|
||||||
|
await expect(link.locator("img")).toHaveAttribute("src", gameCover);
|
||||||
|
await expect.poll(() => link.locator("img").evaluate((img) => img.naturalWidth)).toBeGreaterThan(0);
|
||||||
|
expect((await exportToolbox(page, "Linked cover")).toolbox.customCover).toBe(cover);
|
||||||
|
await link.click();
|
||||||
|
await expect(page.locator(".toolbox-game-icon img")).toHaveAttribute("src", gameCover);
|
||||||
|
await expect(picker(page)).toHaveCount(0);
|
||||||
|
await page.goto("/#/games/mhwilds");
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Lier une toolbox", exact: true });
|
||||||
|
await dialog.getByLabel("Toolbox associée").selectOption("");
|
||||||
|
await dialog.getByRole("button", { name: "Valider" }).click();
|
||||||
|
await drawer.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(link.locator("img")).toHaveAttribute("src", cover);
|
||||||
|
await link.click();
|
||||||
|
await expect(picker(page).locator("img")).toHaveAttribute("src", cover);
|
||||||
|
});
|
||||||
130
e2e/toolbox-drawer-layout.spec.js
Normal file
130
e2e/toolbox-drawer-layout.spec.js
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// Rôle : vérifie le panneau large et la bascule guide/toolbox en fenêtre compacte.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
async function openDrawer(page) {
|
||||||
|
await page.locator(".topbar-actions").waitFor();
|
||||||
|
const compactTrigger = page.getByRole("tab", { name: "Toolbox", exact: true });
|
||||||
|
const trigger = await compactTrigger.isVisible()
|
||||||
|
? compactTrigger
|
||||||
|
: page.getByRole("button", { name: "Afficher la toolbox", exact: true });
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
await trigger.click();
|
||||||
|
if (await compactTrigger.isVisible()) await expect(compactTrigger).toHaveAttribute("aria-selected", "true");
|
||||||
|
else await expect(page.getByRole("button", { name: "Masquer la toolbox", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
const drawer = page.locator("#toolbox-drawer");
|
||||||
|
await expect(drawer.locator(".drawer-panel")).toBeVisible();
|
||||||
|
return drawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resizeDrawer(page, deltaX) {
|
||||||
|
const handle = page.locator(".drawer-resize-handle");
|
||||||
|
const bounds = await handle.boundingBox();
|
||||||
|
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(bounds.x + bounds.width / 2 + deltaX, bounds.y + bounds.height / 2);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("wide drawer reserves 700px while additional width overlays the page", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto("/#/games/mhwilds/monsters");
|
||||||
|
|
||||||
|
const drawer = await openDrawer(page);
|
||||||
|
const panel = drawer.locator(".drawer-panel");
|
||||||
|
const shell = page.locator(".app-shell");
|
||||||
|
const contentShell = page.locator(".content-shell");
|
||||||
|
const main = page.locator(".content-shell > main");
|
||||||
|
const sidebar = page.locator(".sidebar");
|
||||||
|
await expect(shell).toHaveClass(/toolbox-drawer-open/);
|
||||||
|
await expect(sidebar).toHaveAttribute("data-variant", "compact");
|
||||||
|
await expect(sidebar).toHaveCSS("width", "72px");
|
||||||
|
await expect(drawer.locator(".drawer-backdrop")).toHaveCount(0);
|
||||||
|
|
||||||
|
const contentBounds = await contentShell.boundingBox();
|
||||||
|
const initialPanelBounds = await panel.boundingBox();
|
||||||
|
const initialMainBounds = await main.boundingBox();
|
||||||
|
const topbarBounds = await page.locator(".topbar").boundingBox();
|
||||||
|
expect(initialPanelBounds.width).toBeCloseTo(700, 0);
|
||||||
|
expect(initialPanelBounds.x).toBeCloseTo(contentBounds.x + contentBounds.width - 700, 0);
|
||||||
|
expect(topbarBounds.x + topbarBounds.width).toBeCloseTo(1440, 0);
|
||||||
|
|
||||||
|
const search = page.locator(".nds-filter-search input").first();
|
||||||
|
await search.fill("rath");
|
||||||
|
await expect(search).toHaveValue("rath");
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
|
||||||
|
await resizeDrawer(page, -100);
|
||||||
|
await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(800, 0);
|
||||||
|
const expandedPanelBounds = await panel.boundingBox();
|
||||||
|
const expandedMainBounds = await main.boundingBox();
|
||||||
|
expect(expandedPanelBounds.x).toBeCloseTo(initialPanelBounds.x - 100, 0);
|
||||||
|
expect(expandedMainBounds.x).toBeCloseTo(initialMainBounds.x, 0);
|
||||||
|
expect(expandedMainBounds.width).toBeCloseTo(initialMainBounds.width, 0);
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth)).toBeLessThanOrEqual(1440);
|
||||||
|
|
||||||
|
await resizeDrawer(page, 300);
|
||||||
|
await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(700, 0);
|
||||||
|
|
||||||
|
await resizeDrawer(page, -100);
|
||||||
|
await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(800, 0);
|
||||||
|
await drawer.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
await expect(drawer).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Afficher la toolbox", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(sidebar).not.toHaveAttribute("data-variant");
|
||||||
|
await expect(sidebar).toHaveCSS("width", "248px");
|
||||||
|
const reopenedDrawer = await openDrawer(page);
|
||||||
|
await expect.poll(async () => (await reopenedDrawer.locator(".drawer-panel").boundingBox()).width).toBeCloseTo(700, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compact game view switches between mounted guide and toolbox", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 960, height: 720 });
|
||||||
|
await page.goto("/#/games/mhwilds/monsters");
|
||||||
|
|
||||||
|
const guide = page.locator(".route-content-view");
|
||||||
|
const search = page.locator(".nds-filter-search input").first();
|
||||||
|
await search.fill("rath");
|
||||||
|
await page.evaluate(() => globalThis.window.scrollTo(0, 240));
|
||||||
|
const guideScroll = await page.evaluate(() => globalThis.window.scrollY);
|
||||||
|
const viewSwitch = page.locator(".compact-game-view-switch");
|
||||||
|
const guideTab = page.getByRole("tab", { name: "Guide" });
|
||||||
|
const toolboxTab = page.getByRole("tab", { name: "Toolbox", exact: true });
|
||||||
|
await expect(guideTab).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(viewSwitch).toHaveCSS("min-width", "184px");
|
||||||
|
await expect(guideTab).toHaveCSS("min-height", "34px");
|
||||||
|
await expect(toolboxTab).toHaveCSS("padding-left", "10px");
|
||||||
|
const [guideBounds, toolboxBounds] = await Promise.all([guideTab.boundingBox(), toolboxTab.boundingBox()]);
|
||||||
|
expect(guideBounds.width).toBeCloseTo(toolboxBounds.width, 0);
|
||||||
|
await expect(guide).toBeVisible();
|
||||||
|
const drawer = await openDrawer(page);
|
||||||
|
const panel = drawer.locator(".drawer-panel");
|
||||||
|
const bounds = await panel.boundingBox();
|
||||||
|
expect(bounds.x).toBeCloseTo(72, 0);
|
||||||
|
expect(bounds.width).toBeCloseTo(888, 0);
|
||||||
|
await expect(drawer.locator(".drawer-resize-handle")).toHaveCSS("display", "none");
|
||||||
|
await expect(toolboxTab).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(guide).toBeHidden();
|
||||||
|
await guideTab.click();
|
||||||
|
await expect(guide).toBeVisible();
|
||||||
|
await expect(drawer).toBeHidden();
|
||||||
|
await expect(drawer).toHaveCount(1);
|
||||||
|
await expect(search).toHaveValue("rath");
|
||||||
|
await expect.poll(() => page.evaluate(() => globalThis.window.scrollY)).toBe(guideScroll);
|
||||||
|
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth)).toBeLessThanOrEqual(960);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compact toolbox intent survives the 1440px transition", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1439, height: 720 });
|
||||||
|
await page.goto("/#/games/mhwilds/monsters");
|
||||||
|
await openDrawer(page);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 720 });
|
||||||
|
const drawer = page.locator("#toolbox-drawer");
|
||||||
|
await expect(drawer.locator(".drawer-panel")).toBeVisible();
|
||||||
|
await expect(drawer.locator(".drawer-panel")).toHaveCSS("width", "700px");
|
||||||
|
await expect(page.locator(".app-shell")).toHaveClass(/toolbox-drawer-open/);
|
||||||
|
await expect(page.getByRole("button", { name: "Masquer la toolbox", exact: true })).toBeFocused();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1439, height: 720 });
|
||||||
|
await page.getByRole("tab", { name: "Guide" }).click();
|
||||||
|
await page.setViewportSize({ width: 1440, height: 720 });
|
||||||
|
await expect(drawer).toHaveCount(0);
|
||||||
|
});
|
||||||
205
e2e/toolbox-spaces.spec.js
Normal file
205
e2e/toolbox-spaces.spec.js
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
// Rôle : vérifie la gestion, la limite et les déplacements entre espaces toolbox.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
async function seedToolbox(page, toolbox, links = {}) {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
await page.evaluate(async ({ value, links }) => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
await db.setToolboxSnapshot([value], links, {});
|
||||||
|
}, { value: toolbox, links });
|
||||||
|
await page.goto(`/#/toolbox/${toolbox.id}`);
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSpace(page, name) {
|
||||||
|
await page.getByRole("button", { name: "Nouvel espace", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Créer un espace" });
|
||||||
|
await dialog.getByLabel("Nom").fill(name);
|
||||||
|
await dialog.getByRole("button", { name: "Enregistrer" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragModuleToTab(page, title, tab, hoverMs = 0) {
|
||||||
|
await page.getByRole("button", { name: `Déplacer ${title}`, exact: true }).hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
const box = await tab.boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 4 });
|
||||||
|
if (hoverMs) await page.waitForTimeout(hoverMs);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("creates at most five spaces and moves tools with the accessible selector", async ({ page }) => {
|
||||||
|
await seedToolbox(page, {
|
||||||
|
id: "spaces",
|
||||||
|
name: "Spaces",
|
||||||
|
modules: [{ id: "a", type: "counters", title: "Alpha" }],
|
||||||
|
moduleOrder: { one: ["a"], two: [["a"], []] }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab")).toHaveCount(0);
|
||||||
|
await addSpace(page, "Boss");
|
||||||
|
const compactAddButton = page.getByRole("button", { name: "Nouvel espace", exact: true });
|
||||||
|
await expect(compactAddButton).toHaveClass(/toolbox-space-add-compact/);
|
||||||
|
await expect(compactAddButton).not.toContainText("Nouvel espace");
|
||||||
|
for (const name of ["Farm", "Build", "Notes"]) await addSpace(page, name);
|
||||||
|
await expect(page.getByRole("tab")).toHaveCount(5);
|
||||||
|
await expect(page.getByRole("button", { name: "Limite de 5 espaces atteinte" })).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Renommer l’espace actif" }).click();
|
||||||
|
const renameDialog = page.getByRole("dialog", { name: "Renommer l’espace" });
|
||||||
|
await renameDialog.getByLabel("Nom").fill("Divers");
|
||||||
|
await renameDialog.getByRole("button", { name: "Enregistrer" }).click();
|
||||||
|
await expect(page.getByRole("tab", { name: "Divers" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Défaut" }).click();
|
||||||
|
const alpha = page.locator("[data-module-id='a']");
|
||||||
|
await alpha.getByRole("button", { name: "Configuration", exact: true }).click();
|
||||||
|
const organization = alpha.locator(".module-configuration-organization");
|
||||||
|
const data = alpha.locator(".module-configuration-data");
|
||||||
|
await expect(organization).toBeVisible();
|
||||||
|
await expect(data).toBeVisible();
|
||||||
|
const organizationBox = await organization.boundingBox();
|
||||||
|
const dataBox = await data.boundingBox();
|
||||||
|
expect(dataBox.y).toBeCloseTo(organizationBox.y, 0);
|
||||||
|
expect(dataBox.x).toBeGreaterThan(organizationBox.x + organizationBox.width);
|
||||||
|
expect(dataBox.width).toBeCloseTo(organizationBox.width, 0);
|
||||||
|
await alpha.getByRole("combobox", { name: "Déplacer vers un autre espace", exact: true }).selectOption({ label: "Divers" });
|
||||||
|
await expect(page.locator("[data-module-id='a']")).toHaveCount(0);
|
||||||
|
await page.getByRole("tab", { name: "Divers" }).click();
|
||||||
|
await expect(page.locator("[data-module-id='a']")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Supprimer l’espace actif" }).click();
|
||||||
|
await page.getByRole("dialog", { name: "Supprimer l’espace" }).getByRole("button", { name: "Supprimer l’espace actif" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Nouvel espace" })).toBeEnabled();
|
||||||
|
await expect(page.getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.locator("[data-module-id='a']")).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the compact header order and isolates configuration from add and collapsed content", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 900 });
|
||||||
|
await seedToolbox(page, {
|
||||||
|
id: "module-configuration",
|
||||||
|
name: "Module configuration",
|
||||||
|
modules: [{ id: "a", type: "counters", title: "Alpha" }],
|
||||||
|
moduleOrder: { one: ["a"], two: [["a"], []] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = page.locator("[data-module-id='a']");
|
||||||
|
await expect(tool.locator(".module-header-actions > button")).toHaveCount(5);
|
||||||
|
expect(await tool.locator(".module-header-actions > button").evaluateAll((buttons) => buttons.map((button) => button.className.split(" ")[0]))).toEqual([
|
||||||
|
"module-scroll-button",
|
||||||
|
"module-edit-button",
|
||||||
|
"module-configuration-button",
|
||||||
|
"module-delete-button",
|
||||||
|
"module-collapse-button"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const add = tool.getByRole("button", { name: "Afficher l'ajout de Alpha", exact: true });
|
||||||
|
const configuration = tool.getByRole("button", { name: "Configuration", exact: true });
|
||||||
|
await add.click();
|
||||||
|
await expect(tool.locator(".module-add-panel")).toBeVisible();
|
||||||
|
await configuration.click();
|
||||||
|
await expect(add).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(tool.locator(".module-add-panel")).toHaveCount(0);
|
||||||
|
const panel = tool.getByRole("region", { name: "Configuration de Alpha", exact: true });
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(panel.getByRole("button", { name: "Importer du texte", exact: true })).toBeVisible();
|
||||||
|
const toolBox = await tool.boundingBox();
|
||||||
|
const panelBox = await panel.boundingBox();
|
||||||
|
expect(panelBox.width).toBeGreaterThan(toolBox.width - 24);
|
||||||
|
expect(panelBox.width).toBeLessThan(toolBox.width);
|
||||||
|
|
||||||
|
await tool.getByRole("button", { name: "Masquer Alpha", exact: true }).click();
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await expect(tool.locator(".module-content")).toHaveCount(0);
|
||||||
|
await configuration.click();
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(tool.locator(".module-content")).toBeHidden();
|
||||||
|
await expect(panel.getByRole("button", { name: "Importer du texte", exact: true })).toBeVisible();
|
||||||
|
await configuration.press("Escape");
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await expect(configuration).toHaveAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the tab insertion indicator on the correct side while reordering", async ({ page }) => {
|
||||||
|
await seedToolbox(page, {
|
||||||
|
id: "reorder-spaces",
|
||||||
|
name: "Reorder spaces",
|
||||||
|
modules: [],
|
||||||
|
spaces: [
|
||||||
|
{ id: "default", name: "Défaut", moduleOrder: { one: [], two: [[], []] } },
|
||||||
|
{ id: "boss", name: "Boss", moduleOrder: { one: [], two: [[], []] } },
|
||||||
|
{ id: "farm", name: "Farm", moduleOrder: { one: [], two: [[], []] } }
|
||||||
|
],
|
||||||
|
defaultSpaceId: "default",
|
||||||
|
activeSpaceId: "default"
|
||||||
|
});
|
||||||
|
|
||||||
|
const farmTabWrap = page.getByRole("tab", { name: "Farm" }).locator("..");
|
||||||
|
const farmBox = await farmTabWrap.boundingBox();
|
||||||
|
const bossDragHandle = page.getByRole("button", { name: "Réordonner l’espace Boss" });
|
||||||
|
await expect.poll(() => bossDragHandle.evaluate((element) => globalThis.getComputedStyle(element).marginLeft)).toBe("4px");
|
||||||
|
await bossDragHandle.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(farmBox.x + farmBox.width - 2, farmBox.y + farmBox.height / 2, { steps: 4 });
|
||||||
|
await expect(farmTabWrap).toHaveClass(/is-drop-target/);
|
||||||
|
await expect(farmTabWrap).toHaveClass(/drop-after/);
|
||||||
|
await expect.poll(() => farmTabWrap.evaluate((element) => globalThis.getComputedStyle(element, "::before").right)).toBe("0px");
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab")).toHaveText(["Défaut", "Farm", "Boss"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quick drop stays on the source while prolonged hover opens the destination", async ({ page }) => {
|
||||||
|
await seedToolbox(page, {
|
||||||
|
id: "drag-spaces",
|
||||||
|
name: "Drag spaces",
|
||||||
|
modules: [
|
||||||
|
{ id: "a", type: "counters", title: "Alpha" },
|
||||||
|
{ id: "b", type: "counters", title: "Beta" }
|
||||||
|
],
|
||||||
|
spaces: [
|
||||||
|
{ id: "default", name: "Défaut", moduleOrder: { one: ["a", "b"], two: [["a"], ["b"]] } },
|
||||||
|
{ id: "boss", name: "Boss", moduleOrder: { one: [], two: [[], []] } }
|
||||||
|
],
|
||||||
|
defaultSpaceId: "default",
|
||||||
|
activeSpaceId: "default"
|
||||||
|
});
|
||||||
|
const bossTab = page.getByRole("tab", { name: "Boss" });
|
||||||
|
|
||||||
|
await dragModuleToTab(page, "Alpha", bossTab);
|
||||||
|
await expect(page.getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.locator("[data-module-id='a']")).toHaveCount(0);
|
||||||
|
|
||||||
|
await dragModuleToTab(page, "Beta", bossTab, 700);
|
||||||
|
await expect(bossTab).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.locator("[data-module-id='a']")).toBeVisible();
|
||||||
|
await expect(page.locator("[data-module-id='b']")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the linked game drawer shares the persisted active space", async ({ page }) => {
|
||||||
|
await seedToolbox(page, {
|
||||||
|
id: "drawer-spaces",
|
||||||
|
name: "Drawer spaces",
|
||||||
|
modules: [{ id: "a", type: "counters", title: "Alpha" }],
|
||||||
|
spaces: [
|
||||||
|
{ id: "default", name: "Défaut", moduleOrder: { one: [], two: [[], []] } },
|
||||||
|
{ id: "boss", name: "Boss", moduleOrder: { one: ["a"], two: [["a"], []] } }
|
||||||
|
],
|
||||||
|
defaultSpaceId: "default",
|
||||||
|
activeSpaceId: "boss"
|
||||||
|
}, { mhwilds: "drawer-spaces" });
|
||||||
|
|
||||||
|
await page.goto("/#/games/mhwilds");
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
const drawer = page.locator("#toolbox-drawer");
|
||||||
|
await expect(drawer.getByRole("tab", { name: "Boss" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(drawer.locator("[data-module-id='a']")).toBeVisible();
|
||||||
|
await drawer.getByRole("tab", { name: "Défaut" }).click();
|
||||||
|
await expect(drawer.locator("[data-module-id='a']")).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
await expect(page.locator("#toolbox-drawer").getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
62
e2e/toolbox-tutorial.spec.js
Normal file
62
e2e/toolbox-tutorial.spec.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// Rôle : vérifie les étapes, la fermeture et l'isolation des tutoriels toolbox sous StrictMode.
|
||||||
|
/* global window, document, fetch */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/toolbox-tutorial-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountToolboxTutorialProbe } = await import(url);
|
||||||
|
const siteContent = await fetch("/data/site.json").then((response) => response.json());
|
||||||
|
document.getElementById("app").style.display = "none";
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.prepend(element);
|
||||||
|
window.tutorialProbe = mountToolboxTutorialProbe(element, siteContent);
|
||||||
|
}, probeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tutorial restarts on another toolbox and consumes each start flag once", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await setup(page);
|
||||||
|
const tour = page.locator(".toolbox-tutorial-tooltip");
|
||||||
|
const count = tour.locator(".toolbox-tutorial-count");
|
||||||
|
await expect(count).toHaveText(/^1\//);
|
||||||
|
await tour.locator("footer button").click();
|
||||||
|
await expect(count).toHaveText(/^2\//);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({}));
|
||||||
|
await expect(count).toHaveText(/^2\//);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "b" }));
|
||||||
|
await expect(count).toHaveText(/^1\//);
|
||||||
|
expect(await page.evaluate(() => window.tutorialProbe.writes.map(({ id, tutorial }) => ({ id, tutorial })))).toEqual([
|
||||||
|
{ id: "a", tutorial: false }, { id: "b", tutorial: false }
|
||||||
|
]);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "c" }));
|
||||||
|
await expect(tour).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "a" }));
|
||||||
|
await expect(tour).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.unmount());
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending tutorial survives unrelated toolboxes and stays dismissed after rerenders", async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
const tour = page.locator(".toolbox-tutorial-tooltip");
|
||||||
|
await expect(tour).toBeVisible();
|
||||||
|
await tour.locator(".toolbox-tutorial-close").click();
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({ pendingTutorialToolboxId: "c" }));
|
||||||
|
await expect(tour).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => window.tutorialProbe.pendingChanges)).toEqual([]);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "c" }));
|
||||||
|
await expect(tour.locator(".toolbox-tutorial-count")).toHaveText(/^1\//);
|
||||||
|
expect(await page.evaluate(() => window.tutorialProbe.pendingChanges)).toEqual([""]);
|
||||||
|
await tour.locator(".toolbox-tutorial-close").click();
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({}));
|
||||||
|
await expect(tour).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.update({ pendingTutorialToolboxId: "c" }));
|
||||||
|
await expect(tour.locator(".toolbox-tutorial-count")).toHaveText(/^1\//);
|
||||||
|
await page.evaluate(() => window.tutorialProbe.unmount());
|
||||||
|
await expect(tour).toHaveCount(0);
|
||||||
|
});
|
||||||
557
e2e/toolbox.spec.js
Normal file
557
e2e/toolbox.spec.js
Normal file
|
|
@ -0,0 +1,557 @@
|
||||||
|
// Rôle : couvre les parcours navigateur principaux des toolboxes locales.
|
||||||
|
/* global window, document, navigator, CustomEvent, FileSystemDirectoryHandle */
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const fixturePath = path.join(__dirname, "fixtures", "toolbox-export.json");
|
||||||
|
const site = JSON.parse(readFileSync(path.join(__dirname, "..", "website", "public", "data", "site.json"), "utf8"));
|
||||||
|
|
||||||
|
async function collectBrowserErrors(page) {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(`pageerror: ${error.stack || error.message}`));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(`console: ${message.text()}`);
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createToolbox(page, name) {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(page.getByRole("main")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Nouvelle toolbox" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Nouvelle toolbox" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByLabel("Nom").fill(name);
|
||||||
|
await dialog.getByRole("button", { name: "Créer" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTool(page, name) {
|
||||||
|
await page.getByRole("button", { name: "Ajouter un outil", exact: true }).click();
|
||||||
|
const addDialog = page.getByRole("dialog", { name: "Ajouter un outil" });
|
||||||
|
await expect(addDialog).toBeVisible();
|
||||||
|
await addDialog.getByRole("button", { name }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleByType(page, type) {
|
||||||
|
return page.locator(`[data-module-type='${type}']`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableCell(table, address) {
|
||||||
|
return table.getByRole("textbox", { name: `Cellule ${address}`, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await resetSokkoStorage(page);
|
||||||
|
page.browserErrors = await collectBrowserErrors(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
expect(page.browserErrors, page.browserErrors.join("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a toolbox and lazy-loads an added tool", async ({ page }) => {
|
||||||
|
await createToolbox(page, "E2E Toolbox");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "E2E Toolbox" })).toBeVisible();
|
||||||
|
await expect(page.locator("[data-module-type='memos']")).toBeVisible();
|
||||||
|
await expect(page.locator("[data-module-type='links']")).toBeVisible();
|
||||||
|
|
||||||
|
await addTool(page, "Arbre d'objectifs");
|
||||||
|
await expect(page.locator("[data-module-type='goalTree']")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add-tool modal explains every tool without overflowing compact desktop widths", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await createToolbox(page, "Catalogue des outils");
|
||||||
|
await page.getByRole("button", { name: "Ajouter un outil", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Ajouter un outil", exact: true });
|
||||||
|
const cards = dialog.locator(".tool-add-card");
|
||||||
|
const descriptions = Object.entries(site.toolboxes.addToolDescriptions);
|
||||||
|
await expect(cards).toHaveCount(descriptions.length);
|
||||||
|
for (const [index, [, description]] of descriptions.entries()) {
|
||||||
|
await expect(dialog.getByText(description, { exact: true })).toBeVisible();
|
||||||
|
await expect(cards.nth(index).locator("strong")).not.toHaveCSS("text-overflow", "ellipsis");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [width, expectedColumns] of [[768, 4], [960, 5], [1024, 5], [1280, 5]]) {
|
||||||
|
await page.setViewportSize({ width, height: 720 });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
const layout = await dialog.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const grid = element.querySelector(".tool-add-grid");
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
height: rect.height,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
columns: window.getComputedStyle(grid).gridTemplateColumns.split(" ").length,
|
||||||
|
overflowY: window.getComputedStyle(element).overflowY,
|
||||||
|
scrollbarColor: window.getComputedStyle(element).scrollbarColor,
|
||||||
|
scrollbarWidth: window.getComputedStyle(element).scrollbarWidth,
|
||||||
|
webkitScrollbarWidth: window.getComputedStyle(element, "::-webkit-scrollbar").width,
|
||||||
|
scrolls: element.scrollHeight > element.clientHeight,
|
||||||
|
pageFits: document.documentElement.scrollWidth <= window.innerWidth
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(layout.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(layout.right).toBeLessThanOrEqual(layout.viewportWidth);
|
||||||
|
expect(layout.right - layout.left).toBeGreaterThanOrEqual(Math.min(900, width - 32) - 2);
|
||||||
|
expect(layout.height).toBeLessThanOrEqual(layout.viewportHeight);
|
||||||
|
expect(layout.columns).toBe(expectedColumns);
|
||||||
|
expect(layout.overflowY).toBe("auto");
|
||||||
|
expect(layout.scrollbarColor).toContain("88, 55, 174");
|
||||||
|
expect(layout.scrollbarWidth).toBe("thin");
|
||||||
|
expect(layout.webkitScrollbarWidth).toBe("6px");
|
||||||
|
expect(layout.scrolls).toBe(true);
|
||||||
|
expect(layout.pageFits).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorySections = dialog.locator(".tool-add-category-section");
|
||||||
|
await expect(categorySections).toHaveCount(5);
|
||||||
|
await expect(categorySections.locator("h3")).toHaveText(Object.values(site.library.categories));
|
||||||
|
await expect(dialog.locator("[aria-pressed]")).toHaveCount(0);
|
||||||
|
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Afficher l'ajout rapide", exact: true }).click();
|
||||||
|
const quickImage = page.getByRole("button", { name: "Ajouter Images", exact: true });
|
||||||
|
await quickImage.focus();
|
||||||
|
await expect(page.getByRole("tooltip", { name: "Images", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an uncommitted memo through unrelated app updates and saves multiline text", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Memo draft toolbox");
|
||||||
|
const memos = moduleByType(page, "memos");
|
||||||
|
const editor = memos.getByRole("textbox", { name: "Modifier le mémo" }).first();
|
||||||
|
await editor.fill("Premiere ligne\nDeuxieme ligne");
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new CustomEvent("sokkog:notify", {
|
||||||
|
detail: { message: "Memo draft notification" }
|
||||||
|
})));
|
||||||
|
await expect(page.getByText("Memo draft notification", { exact: true })).toBeVisible();
|
||||||
|
await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne");
|
||||||
|
await editor.press("End");
|
||||||
|
await editor.press("Enter");
|
||||||
|
await editor.pressSequentially("Troisieme ligne");
|
||||||
|
await editor.blur();
|
||||||
|
await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne\nTroisieme ligne");
|
||||||
|
await page.reload();
|
||||||
|
await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne\nTroisieme ligne");
|
||||||
|
await editor.fill(" Premiere ligne\nDeuxieme ligne\nTroisieme ligne ");
|
||||||
|
await editor.blur();
|
||||||
|
await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne\nTroisieme ligne");
|
||||||
|
await editor.fill(" ");
|
||||||
|
await editor.blur();
|
||||||
|
await expect(memos.locator(".memo-card")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates, edits and deletes a weekly calendar event", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Calendar E2E Toolbox");
|
||||||
|
await addTool(page, "Calendrier");
|
||||||
|
|
||||||
|
const calendar = page.locator("[data-module-type='calendar']");
|
||||||
|
await expect(calendar).toBeVisible();
|
||||||
|
await calendar.getByRole("button", { name: "Hebdomadaire" }).click();
|
||||||
|
await expect(calendar.locator(".calendar-week-grid")).toBeVisible();
|
||||||
|
|
||||||
|
await calendar.getByRole("button", { name: /Afficher l'ajout de Calendrier/ }).click();
|
||||||
|
await calendar.getByLabel("Titre").fill("Raid E2E");
|
||||||
|
await calendar.getByRole("textbox", { name: "Début" }).fill("18:00");
|
||||||
|
await calendar.getByRole("textbox", { name: "Fin" }).fill("20:00");
|
||||||
|
await calendar.getByRole("button", { name: "Ajouter" }).click();
|
||||||
|
|
||||||
|
await expect(calendar.getByRole("button", { name: /Raid E2E/ })).toBeVisible();
|
||||||
|
await calendar.getByRole("button", { name: /Raid E2E/ }).click();
|
||||||
|
await calendar.getByLabel("Titre").fill("Raid E2E modifié");
|
||||||
|
await calendar.getByRole("button", { name: "Enregistrer" }).click();
|
||||||
|
|
||||||
|
await expect(calendar.getByRole("button", { name: /Raid E2E modifié/ })).toBeVisible();
|
||||||
|
await expect(calendar.getByRole("button", { name: /Raid E2E$/ })).toHaveCount(0);
|
||||||
|
|
||||||
|
await calendar.getByRole("button", { name: /Raid E2E modifié/ }).click();
|
||||||
|
await calendar.getByRole("button", { name: "Supprimer" }).click();
|
||||||
|
await expect(calendar.getByRole("button", { name: /Raid E2E modifié/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates weekly and monthly calendar drafts when extending a selection", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Calendar selection E2E");
|
||||||
|
await addTool(page, "Calendrier");
|
||||||
|
const weekly = moduleByType(page, "calendar").first();
|
||||||
|
await weekly.getByRole("button", { name: "Hebdomadaire" }).click();
|
||||||
|
await weekly.getByRole("button", { name: /Afficher l'ajout de Calendrier/ }).click();
|
||||||
|
const slots = weekly.locator(".calendar-day-column").first().locator(".calendar-slot");
|
||||||
|
await slots.nth(21).dispatchEvent("pointerdown", { buttons: 1 });
|
||||||
|
await slots.nth(18).dispatchEvent("pointerover", { buttons: 1 });
|
||||||
|
await slots.nth(18).dispatchEvent("pointerup", { buttons: 0 });
|
||||||
|
await expect(weekly.getByRole("textbox", { name: "Début" })).toHaveValue("09:00");
|
||||||
|
await expect(weekly.getByRole("textbox", { name: "Fin" })).toHaveValue("11:00");
|
||||||
|
|
||||||
|
await addTool(page, "Calendrier");
|
||||||
|
const monthly = moduleByType(page, "calendar").last();
|
||||||
|
await monthly.getByRole("button", { name: "Mensuel" }).click();
|
||||||
|
await monthly.getByRole("button", { name: /Afficher l'ajout de Calendrier/ }).click();
|
||||||
|
const days = monthly.locator(".calendar-month-cell:not(.is-outside)");
|
||||||
|
await days.nth(4).dispatchEvent("pointerdown", { buttons: 1 });
|
||||||
|
await days.nth(2).dispatchEvent("pointerover", { buttons: 1 });
|
||||||
|
await expect(monthly.locator(".calendar-month-cell.is-selected")).toHaveCount(3);
|
||||||
|
await days.nth(2).dispatchEvent("pointerup", { buttons: 0 });
|
||||||
|
await expect(monthly.locator(".calendar-event-form")).toBeVisible();
|
||||||
|
await expect(monthly.locator(".calendar-month-cell.is-form-selected")).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a goal tree checklist with more than three visible lines", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Goal Tree E2E Toolbox");
|
||||||
|
await addTool(page, "Arbre d'objectifs");
|
||||||
|
|
||||||
|
const goalTree = page.locator("[data-module-type='goalTree']");
|
||||||
|
await expect(goalTree).toBeVisible();
|
||||||
|
await goalTree.getByRole("button", { name: /Afficher l'ajout de Arbre d'objectifs/ }).click();
|
||||||
|
await goalTree.getByRole("button", { name: "Ajouter un objectif a la racine" }).click();
|
||||||
|
|
||||||
|
const editor = goalTree.getByRole("region", { name: "Édition de l'arbre d'objectifs" });
|
||||||
|
await expect(editor).toBeVisible();
|
||||||
|
await editor.getByLabel("Titre de l'objectif").fill("Farm E2E");
|
||||||
|
await editor.getByLabel("Type de l'objectif").selectOption("checklist");
|
||||||
|
await editor.getByRole("tab", { name: "Contenu" }).click();
|
||||||
|
|
||||||
|
for (const index of [1, 2, 3, 4, 5]) {
|
||||||
|
await editor.getByRole("button", { name: "Ajouter une ligne" }).click();
|
||||||
|
await editor.getByLabel("Libellé").nth(index - 1).fill(`Objectif ligne ${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewRows = goalTree.locator(".goal-tree-node-content-row");
|
||||||
|
await expect(previewRows).toHaveCount(5);
|
||||||
|
await expect(previewRows.filter({ hasText: "Objectif ligne 1" })).toBeVisible();
|
||||||
|
await expect(previewRows.filter({ hasText: "Objectif ligne 5" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads randomizer tabs after module split", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Randomizer E2E Toolbox");
|
||||||
|
await addTool(page, "Randomizer");
|
||||||
|
|
||||||
|
const randomizer = moduleByType(page, "randomizer");
|
||||||
|
await expect(randomizer).toBeVisible();
|
||||||
|
await expect(randomizer.getByRole("tab", { name: "Dé" })).toBeVisible();
|
||||||
|
await expect(randomizer.getByRole("button", { name: "Lancer" })).toBeVisible();
|
||||||
|
|
||||||
|
await randomizer.getByRole("tab", { name: "Roue" }).click();
|
||||||
|
await expect(randomizer.getByLabel("Liste de valeurs")).toBeVisible();
|
||||||
|
await randomizer.getByLabel("Liste de valeurs").fill("Rouge\nBleu");
|
||||||
|
await randomizer.getByRole("button", { name: "Ajouter" }).click();
|
||||||
|
await expect(randomizer.locator(".randomizer-wheel-value-row .nds-input").first()).toHaveValue("Rouge");
|
||||||
|
|
||||||
|
await randomizer.getByRole("tab", { name: "Équipes" }).click();
|
||||||
|
await expect(randomizer.getByLabel("Liste de noms")).toBeVisible();
|
||||||
|
await expect(randomizer.getByRole("button", { name: "Créer les équipes" })).toBeVisible();
|
||||||
|
await randomizer.getByRole("combobox", { name: "Répartition", exact: true }).selectOption("count");
|
||||||
|
const count = randomizer.getByRole("spinbutton", { name: "Nombre", exact: true });
|
||||||
|
await count.fill("5");
|
||||||
|
await count.blur();
|
||||||
|
await expect(count).toHaveValue("5");
|
||||||
|
await randomizer.getByRole("spinbutton", { name: "Équipes", exact: true }).fill("6");
|
||||||
|
await expect(count).toHaveValue("6");
|
||||||
|
await count.fill("");
|
||||||
|
await count.blur();
|
||||||
|
await expect(count).toHaveValue("6");
|
||||||
|
await randomizer.getByRole("tab", { name: "Dé", exact: true }).click();
|
||||||
|
await randomizer.getByRole("tab", { name: "Équipes", exact: true }).click();
|
||||||
|
await expect(count).toHaveValue("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a game-linked toolbox from the drawer", async ({ page }) => {
|
||||||
|
await page.goto("/#/games/mhwilds");
|
||||||
|
await expect(page.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
|
||||||
|
const drawer = page.locator("#toolbox-drawer .drawer-panel");
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
await drawer.getByRole("button", { name: "Créer et associer" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Créer et associer une toolbox" });
|
||||||
|
await expect(dialog.getByLabel("Nom")).toHaveValue(/Monster Hunter.*Wilds/);
|
||||||
|
await dialog.getByRole("button", { name: "Créer" }).click();
|
||||||
|
|
||||||
|
await expect(drawer.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
|
||||||
|
await expect(drawer.locator("[data-module-type='memos']")).toBeVisible();
|
||||||
|
await expect(drawer.locator("[data-module-type='links']")).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: "Toolbox", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("links an available toolbox directly from the empty game drawer", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Toolbox déjà liée");
|
||||||
|
await page.goto("/#/games/mhwilds");
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
|
||||||
|
const drawer = page.locator("#toolbox-drawer .drawer-panel");
|
||||||
|
await expect(drawer.getByRole("heading", { name: "Ou associer une toolbox déjà existante" })).toBeVisible();
|
||||||
|
await drawer.getByLabel("Toolbox disponible").selectOption({ label: "Toolbox déjà liée" });
|
||||||
|
await drawer.getByRole("button", { name: "Associer", exact: true }).click();
|
||||||
|
await expect(drawer.getByRole("heading", { name: "Toolbox déjà liée", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await createToolbox(page, "Toolbox libre");
|
||||||
|
await page.goto("/#/games/diablo4");
|
||||||
|
await page.getByRole("tab", { name: "Toolbox", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(drawer.getByLabel("Toolbox disponible").getByRole("option", { name: "Toolbox déjà liée" })).toHaveCount(0);
|
||||||
|
await drawer.getByLabel("Toolbox disponible").selectOption({ label: "Toolbox libre" });
|
||||||
|
await drawer.getByRole("button", { name: "Associer", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(drawer.getByRole("heading", { name: "Toolbox libre", exact: true })).toBeVisible();
|
||||||
|
await expect(drawer.getByRole("heading", { name: "Ou associer une toolbox déjà existante" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports and exports a toolbox payload", async ({ page }) => {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(page.getByRole("main")).toBeVisible();
|
||||||
|
await page.getByText("Importer une toolbox").locator("input[type='file']").setInputFiles(fixturePath);
|
||||||
|
const importedToolboxLink = page.getByRole("link", { name: "Ouvrir Fixture Toolbox" }).first();
|
||||||
|
await expect(importedToolboxLink).toBeVisible();
|
||||||
|
await importedToolboxLink.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Fixture Toolbox" })).toBeVisible();
|
||||||
|
const importedNotepad = page.locator("[data-module-type='notepad']");
|
||||||
|
await expect(importedNotepad).toBeVisible();
|
||||||
|
await expect(importedNotepad.getByRole("heading", { name: "Fixture Notes" })).toBeVisible();
|
||||||
|
await expect(importedNotepad.getByText("Imported fixture note")).toBeVisible();
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent("download");
|
||||||
|
await page.getByRole("button", { name: "Exporter Fixture Toolbox" }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe("fixture-toolbox.sokkog.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens the local automatic backup settings modal", async ({ page }) => {
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(page.getByRole("button", { name: "Sauvegarde automatique", exact: true })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Sauvegarde automatique", exact: true }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Sauvegarde automatique" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect((await dialog.boundingBox()).width).toBeGreaterThan(700);
|
||||||
|
expect(await dialog.evaluate((element) => window.getComputedStyle(element).scrollbarColor)).toContain("88, 55, 174");
|
||||||
|
await expect(dialog.getByText("Cette fonctionnalité n’est pas disponible dans tous les navigateurs.")).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Opera GX", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Comment fonctionne la sauvegarde", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("la popup système ne peut apparaître qu’après un clic de votre part.")).toBeVisible();
|
||||||
|
await expect(dialog.getByText("10 versions sont conservées.", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Dossier configuré :", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Aucun dossier", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByRole("checkbox")).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Fermer", exact: true }).last().click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers an actionable backup alert without requesting permission at startup", async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
const directoryHandle = await navigator.storage.getDirectory();
|
||||||
|
await db.setSetting("autoBackup", {
|
||||||
|
enabled: true,
|
||||||
|
directoryHandle,
|
||||||
|
lastBackup: "",
|
||||||
|
lastBackupSignature: "",
|
||||||
|
hasUnsavedChanges: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.backupPermissionRequests = 0;
|
||||||
|
window.backupPermissionGranted = false;
|
||||||
|
window.backupReminderVisibleAtPermissionRequest = true;
|
||||||
|
Object.defineProperty(FileSystemDirectoryHandle.prototype, "queryPermission", {
|
||||||
|
configurable: true,
|
||||||
|
value: async () => window.backupPermissionGranted ? "granted" : "prompt"
|
||||||
|
});
|
||||||
|
Object.defineProperty(FileSystemDirectoryHandle.prototype, "requestPermission", {
|
||||||
|
configurable: true,
|
||||||
|
value: async () => {
|
||||||
|
window.backupPermissionRequests += 1;
|
||||||
|
window.backupReminderVisibleAtPermissionRequest = document.body.textContent.includes("Des modifications ont été détectées dans vos toolboxes.");
|
||||||
|
window.backupPermissionGranted = true;
|
||||||
|
return "granted";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.goto("/#/toolboxes");
|
||||||
|
await expect(page.getByText("Des modifications ont été détectées dans vos toolboxes.")).toHaveCount(0);
|
||||||
|
await createToolbox(page, "Toolbox à sauvegarder");
|
||||||
|
|
||||||
|
const reminder = page.getByRole("status").filter({ hasText: "Des modifications ont été détectées dans vos toolboxes." });
|
||||||
|
await expect(reminder).toBeVisible();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Sauvegarde automatique" })).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => window.backupPermissionRequests)).toBe(0);
|
||||||
|
|
||||||
|
await reminder.getByRole("button", { name: "Sauvegarder", exact: true }).click();
|
||||||
|
await expect(reminder).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => window.backupPermissionRequests)).toBe(1);
|
||||||
|
expect(await page.evaluate(() => window.backupReminderVisibleAtPermissionRequest)).toBe(false);
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
return (await db.getSetting("autoBackup", {})).hasUnsavedChanges;
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("backs up silently when folder access is still granted", async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const db = await import("/src/utils/indexedDbStorage.js");
|
||||||
|
const directoryHandle = await navigator.storage.getDirectory();
|
||||||
|
for await (const [name, handle] of directoryHandle.entries()) {
|
||||||
|
if (handle.kind === "file" && /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/.test(name)) await directoryHandle.removeEntry(name);
|
||||||
|
}
|
||||||
|
await db.setSetting("autoBackup", {
|
||||||
|
enabled: true,
|
||||||
|
directoryHandle,
|
||||||
|
lastBackup: "",
|
||||||
|
lastBackupSignature: "",
|
||||||
|
hasUnsavedChanges: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.backupPermissionRequests = 0;
|
||||||
|
Object.defineProperty(FileSystemDirectoryHandle.prototype, "queryPermission", {
|
||||||
|
configurable: true,
|
||||||
|
value: async () => "granted"
|
||||||
|
});
|
||||||
|
Object.defineProperty(FileSystemDirectoryHandle.prototype, "requestPermission", {
|
||||||
|
configurable: true,
|
||||||
|
value: async () => {
|
||||||
|
window.backupPermissionRequests += 1;
|
||||||
|
return "granted";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const directoryHandle = await navigator.storage.getDirectory();
|
||||||
|
for await (const [name, handle] of directoryHandle.entries()) {
|
||||||
|
if (handle.kind === "file" && /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/.test(name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})).toBe(true);
|
||||||
|
await expect(page.getByText("Des modifications ont été détectées dans vos toolboxes.")).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => window.backupPermissionRequests)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edits a table with formulas and persists values", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Table E2E Toolbox");
|
||||||
|
await addTool(page, "Tableau");
|
||||||
|
|
||||||
|
const table = moduleByType(page, "table");
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
await table.getByRole("button", { name: "Ajouter une ligne" }).click();
|
||||||
|
await table.getByRole("button", { name: "Ajouter une colonne" }).click();
|
||||||
|
|
||||||
|
await tableCell(table, "A1").dblclick();
|
||||||
|
await tableCell(table, "A1").fill("12");
|
||||||
|
await tableCell(table, "B1").dblclick();
|
||||||
|
await tableCell(table, "B1").fill("3");
|
||||||
|
await tableCell(table, "C1").dblclick();
|
||||||
|
await tableCell(table, "C1").fill("=A1*B1");
|
||||||
|
await tableCell(table, "C1").press("Enter");
|
||||||
|
|
||||||
|
await expect(tableCell(table, "C1")).toHaveValue("36");
|
||||||
|
await page.reload();
|
||||||
|
await expect(tableCell(moduleByType(page, "table"), "A1")).toHaveValue("12");
|
||||||
|
await expect(tableCell(moduleByType(page, "table"), "C1")).toHaveValue("36");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates edits and deletes a combo", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Combos E2E Toolbox");
|
||||||
|
await addTool(page, "Combos");
|
||||||
|
|
||||||
|
const combos = moduleByType(page, "combos");
|
||||||
|
await expect(combos).toBeVisible();
|
||||||
|
await combos.getByRole("button", { name: /Afficher l'ajout de Combos/ }).click();
|
||||||
|
await combos.getByPlaceholder("Nom du combo").fill("Combo E2E");
|
||||||
|
await combos.getByRole("button", { name: "←" }).click();
|
||||||
|
await combos.getByRole("button", { name: "□" }).click();
|
||||||
|
await combos.getByRole("button", { name: "Ajouter le combo" }).click();
|
||||||
|
|
||||||
|
await expect(combos.locator(".combo-card .combo-input-token")).toHaveCount(2);
|
||||||
|
|
||||||
|
await combos.getByRole("button", { name: "Modifier le combo Combo E2E" }).click();
|
||||||
|
await combos.getByPlaceholder("Nom du combo").fill("Combo E2E modifié");
|
||||||
|
await combos.getByRole("button", { name: "Enregistrer" }).click();
|
||||||
|
await expect(combos.getByRole("button", { name: "Modifier le combo Combo E2E modifié" })).toBeVisible();
|
||||||
|
|
||||||
|
await combos.getByRole("button", { name: "Supprimer le combo Combo E2E modifié" }).click();
|
||||||
|
await expect(combos.getByRole("button", { name: "Modifier le combo Combo E2E modifié" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates edits summarizes and deletes equipment", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Equipment E2E Toolbox");
|
||||||
|
await addTool(page, "Planificateur d'équipements");
|
||||||
|
|
||||||
|
const equipmentPlanner = moduleByType(page, "equipmentPlanner");
|
||||||
|
await expect(equipmentPlanner).toBeVisible();
|
||||||
|
await equipmentPlanner.getByRole("button", { name: /Afficher l'ajout de Planificateur d'équipements/ }).click();
|
||||||
|
await equipmentPlanner.getByPlaceholder("Nouvel équipement").fill("Épée E2E");
|
||||||
|
await equipmentPlanner.getByRole("button", { name: "Ajouter" }).click();
|
||||||
|
|
||||||
|
await expect(equipmentPlanner.getByText("Épée E2E")).toBeVisible();
|
||||||
|
await expect(equipmentPlanner.getByText("1 équipement actif")).toBeVisible();
|
||||||
|
await equipmentPlanner.locator(".equipment-planner-row-edit-button").click();
|
||||||
|
await equipmentPlanner.getByPlaceholder("Catégorie").fill("Stats");
|
||||||
|
await equipmentPlanner.getByPlaceholder("Nom").fill("Attaque");
|
||||||
|
await equipmentPlanner.getByPlaceholder("Valeur").fill("12");
|
||||||
|
await equipmentPlanner.locator(".equipment-planner-trait-form").getByRole("button", { name: "Ajouter" }).click();
|
||||||
|
|
||||||
|
await expect(equipmentPlanner.getByText("Attaque").first()).toBeVisible();
|
||||||
|
await expect(equipmentPlanner.getByText("12").first()).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
const persistedPlanner = moduleByType(page, "equipmentPlanner");
|
||||||
|
await expect(persistedPlanner.getByText("Épée E2E")).toBeVisible();
|
||||||
|
await expect(persistedPlanner.getByText("Attaque").first()).toBeVisible();
|
||||||
|
|
||||||
|
await persistedPlanner.getByRole("button", { name: "Supprimer Épée E2E" }).click();
|
||||||
|
await expect(persistedPlanner.getByText("Épée E2E")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edits rich notepad content and persists it", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Notepad E2E Toolbox");
|
||||||
|
await addTool(page, "Bloc Note");
|
||||||
|
|
||||||
|
const notepad = moduleByType(page, "notepad");
|
||||||
|
await expect(notepad).toBeVisible();
|
||||||
|
const editor = notepad.getByRole("textbox");
|
||||||
|
await editor.click();
|
||||||
|
await page.keyboard.press("Control+A");
|
||||||
|
await page.keyboard.press("Backspace");
|
||||||
|
await notepad.locator(".notepad-toolbar-button[title='Titre']").click();
|
||||||
|
await page.keyboard.type("Note E2E importante");
|
||||||
|
|
||||||
|
await expect(editor).toContainText("Note E2E importante");
|
||||||
|
await expect(notepad.getByRole("heading", { name: "Note E2E importante" })).toBeVisible();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.reload();
|
||||||
|
const persistedEditor = moduleByType(page, "notepad").getByRole("textbox");
|
||||||
|
await expect(persistedEditor).toContainText("Note E2E importante");
|
||||||
|
await expect(moduleByType(page, "notepad").getByRole("heading", { name: "Note E2E importante" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates resets and deletes a countdown timer", async ({ page }) => {
|
||||||
|
await createToolbox(page, "Timer E2E Toolbox");
|
||||||
|
await addTool(page, "Timer");
|
||||||
|
|
||||||
|
const timer = moduleByType(page, "timer");
|
||||||
|
await expect(timer).toBeVisible();
|
||||||
|
await timer.getByRole("tab", { name: "Compte à rebours" }).click();
|
||||||
|
await timer.getByLabel("Libellé").fill("Boss E2E");
|
||||||
|
const durationParts = timer.getByRole("group", { name: "Format heure minute seconde" }).first().locator("input");
|
||||||
|
await durationParts.nth(1).fill("01");
|
||||||
|
await timer.getByRole("button", { name: "Ajouter" }).click();
|
||||||
|
|
||||||
|
const countdown = timer.locator(".timer-list-item").filter({ hasText: "Boss E2E" });
|
||||||
|
await expect(countdown).toBeVisible();
|
||||||
|
await countdown.locator(".timer-inline-reset").click();
|
||||||
|
await expect(countdown).toBeVisible();
|
||||||
|
await countdown.getByRole("button", { name: "Supprimer" }).click();
|
||||||
|
await expect(countdown).toHaveCount(0);
|
||||||
|
});
|
||||||
65
eslint.config.js
Normal file
65
eslint.config.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Rôle : configure ESLint pour le code React, les hooks et les scripts Node du projet.
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"node_modules/**",
|
||||||
|
"website/dist/**",
|
||||||
|
"playwright-report/**",
|
||||||
|
"test-results/**",
|
||||||
|
".codex-home/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["website/src/**/*.{js,jsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.es2024
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"no-unused-vars": ["error", {
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_"
|
||||||
|
}],
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["*.js", "*.mjs", "scripts/**/*.mjs", "tests/**/*.mjs"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.es2024
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": ["error", {
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
2804
package-lock.json
generated
Normal file
2804
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "sokko-g",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs",
|
||||||
|
"dev": "npm run lists:index && vite",
|
||||||
|
"build": "npm run lists:index && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lists:index": "node scripts/generate-list-indexes.mjs",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"check": "node --check server.mjs && node --check vite.config.js && npm run lint && npm run lists:index && npm test && npm run build",
|
||||||
|
"check:full": "npm run check && npm run test:e2e && npm run test:e2e:production",
|
||||||
|
"test": "node --test",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:production": "npm run build && playwright test --config playwright.production.config.js",
|
||||||
|
"profile:toolboxes": "npm run build && node scripts/profile-toolbox-loading.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"vite": "^8.1.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
|
"eslint": "^10.8.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.4",
|
||||||
|
"globals": "^17.11.0",
|
||||||
|
"sass": "^1.101.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
playwright.config.js
Normal file
28
playwright.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Rôle : configure les diagnostics navigateur Playwright sans les intégrer au check de prod.
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
testIgnore: "**/production/**",
|
||||||
|
fullyParallel: true,
|
||||||
|
// Le serveur Vite partagé compile les outils lazy à la première visite.
|
||||||
|
workers: 2,
|
||||||
|
reporter: [["list"], ["html", { open: "never" }]],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:5173",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure"
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:5173",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120000
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
21
playwright.production.config.js
Normal file
21
playwright.production.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Rôle : vérifie le build de production sans réutiliser une instance Vite de développement.
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/production",
|
||||||
|
workers: 1,
|
||||||
|
outputDir: "test-results/production",
|
||||||
|
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report/production" }]],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:4173",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure"
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "npm run preview -- --host 127.0.0.1 --port 4173 --strictPort",
|
||||||
|
url: "http://127.0.0.1:4173",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120000
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium-production", use: { ...devices["Desktop Chrome"] } }]
|
||||||
|
});
|
||||||
52
scripts/generate-list-indexes.mjs
Normal file
52
scripts/generate-list-indexes.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Rôle : génère les index JSON des listes de jeux à partir des fichiers éditables.
|
||||||
|
import { access, readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const DATA_DIRECTORY = "website/public/data";
|
||||||
|
|
||||||
|
function formatTitle(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/\.json$/i, "")
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateListIndex(directory) {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const files = entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b, "fr"));
|
||||||
|
|
||||||
|
const lists = await Promise.all(files.map(async (file) => {
|
||||||
|
const payload = JSON.parse(await readFile(path.join(directory, file), "utf8"));
|
||||||
|
const id = file.replace(/\.json$/i, "");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: payload.titre || formatTitle(id),
|
||||||
|
file
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
await writeFile(path.join(directory, "index.json"), `${JSON.stringify({ lists }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findListDirectories() {
|
||||||
|
const entries = await readdir(DATA_DIRECTORY, { withFileTypes: true });
|
||||||
|
const directories = await Promise.all(entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map(async (entry) => {
|
||||||
|
const listDirectory = path.join(DATA_DIRECTORY, entry.name, "lists");
|
||||||
|
try {
|
||||||
|
await access(listDirectory);
|
||||||
|
return listDirectory;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return directories.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDirectories = await findListDirectories();
|
||||||
|
await Promise.all(listDirectories.map(generateListIndex));
|
||||||
10
scripts/load-project-env.mjs
Normal file
10
scripts/load-project-env.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Rôle : charge le fichier .env facultatif avec le parseur natif de Node.
|
||||||
|
import { loadEnvFile } from "node:process";
|
||||||
|
|
||||||
|
export function loadProjectEnv(file = ".env") {
|
||||||
|
try {
|
||||||
|
loadEnvFile(file);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
scripts/profile-toolbox-loading.mjs
Normal file
186
scripts/profile-toolbox-loading.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
// Rôle : mesure le démarrage du build de production avec des toolboxes synthétiques dans un navigateur isolé.
|
||||||
|
/* global window, document, indexedDB, IDBObjectStore, MutationObserver, requestAnimationFrame */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { cpus, platform } from "node:os";
|
||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
import { preview } from "vite";
|
||||||
|
|
||||||
|
const repetitions = 5;
|
||||||
|
const scenarios = [
|
||||||
|
{ name: "empty", toolboxes: 0, imagesPerToolbox: 0 },
|
||||||
|
{ name: "40-light", toolboxes: 40, imagesPerToolbox: 0 },
|
||||||
|
{ name: "1-heavy", toolboxes: 1, imagesPerToolbox: 160 },
|
||||||
|
{ name: "40-media", toolboxes: 40, imagesPerToolbox: 4 }
|
||||||
|
];
|
||||||
|
const requested = process.argv.slice(2);
|
||||||
|
for (const name of requested) assert.ok(scenarios.some((scenario) => scenario.name === name), `Unknown scenario: ${name}`);
|
||||||
|
|
||||||
|
async function seed(page, scenario) {
|
||||||
|
await page.route("**/__profile-seed", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><title>Profile seed</title>" }));
|
||||||
|
await page.goto("/__profile-seed");
|
||||||
|
return page.evaluate(async ({ toolboxes: count, imagesPerToolbox }) => {
|
||||||
|
// Le PRNG fixe le contenu et la taille des images, indépendamment du regroupement en toolboxes.
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 1024;
|
||||||
|
canvas.height = 768;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const pixels = context.createImageData(canvas.width, canvas.height);
|
||||||
|
let random = 123456;
|
||||||
|
const boxes = [];
|
||||||
|
const modules = [];
|
||||||
|
let mediaBytes = 0;
|
||||||
|
for (let box = 0; box < count; box += 1) {
|
||||||
|
const id = `profile-${box}`;
|
||||||
|
const images = [];
|
||||||
|
for (let image = 0; image < imagesPerToolbox; image += 1) {
|
||||||
|
for (let pixel = 0; pixel < pixels.data.length; pixel += 4) {
|
||||||
|
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
|
||||||
|
pixels.data[pixel] = random & 255;
|
||||||
|
pixels.data[pixel + 1] = (random >>> 8) & 255;
|
||||||
|
pixels.data[pixel + 2] = (random >>> 16) & 255;
|
||||||
|
pixels.data[pixel + 3] = 255;
|
||||||
|
}
|
||||||
|
context.putImageData(pixels, 0, 0);
|
||||||
|
const dataUrl = canvas.toDataURL("image/webp", 0.85);
|
||||||
|
mediaBytes += dataUrl.length;
|
||||||
|
images.push({ id: `image-${image}`, label: `Image ${image}`, dataUrl });
|
||||||
|
}
|
||||||
|
boxes.push({
|
||||||
|
id, name: `Profile ${box}`, updatedAt: "2026-09-12T12:00:00.000Z",
|
||||||
|
modules: [{ id: "memos", type: "memos", title: "Mémos" }, { id: "images", type: "images", title: "Images" }]
|
||||||
|
});
|
||||||
|
modules.push({ key: `${id}:memos`, data: { items: [{ id: "memo", text: "Profil de chargement" }] } });
|
||||||
|
if (images.length) modules.push({ key: `${id}:images`, data: { images } });
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open("sokkog", 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
request.result.createObjectStore("kv");
|
||||||
|
request.result.createObjectStore("modules", { keyPath: "key" });
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const transaction = db.transaction(["kv", "modules"], "readwrite");
|
||||||
|
transaction.objectStore("kv").put(boxes, "toolboxes");
|
||||||
|
for (const module of modules) transaction.objectStore("modules").put(module);
|
||||||
|
transaction.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
transaction.onabort = () => { db.close(); reject(transaction.error); };
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { mediaBytes, moduleCount: modules.length, imageCount: count * imagesPerToolbox };
|
||||||
|
}, scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function instrument(page, expectedCards) {
|
||||||
|
await page.addInitScript((count) => {
|
||||||
|
const metrics = { timeOrigin: performance.timeOrigin, reads: [], longTasks: [], readyMs: null };
|
||||||
|
window.toolboxProfile = metrics;
|
||||||
|
const observer = new MutationObserver(checkReady);
|
||||||
|
observer.observe(document, { childList: true, subtree: true });
|
||||||
|
let pendingPaint = false;
|
||||||
|
function checkReady() {
|
||||||
|
if (pendingPaint || !metrics.reads.some((read) => read.transactionMs != null)) return;
|
||||||
|
if (!document.querySelector(".toolbox-page") || document.querySelectorAll(".toolbox-card").length !== count) return;
|
||||||
|
pendingPaint = true;
|
||||||
|
observer.disconnect();
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => { metrics.readyMs = performance.now(); }));
|
||||||
|
}
|
||||||
|
for (const method of ["getAll", "getAllKeys", "get"]) {
|
||||||
|
const original = IDBObjectStore.prototype[method];
|
||||||
|
IDBObjectStore.prototype[method] = function (...args) {
|
||||||
|
const start = performance.now();
|
||||||
|
const request = original.apply(this, args);
|
||||||
|
if (this.name === "modules") {
|
||||||
|
const read = { method, key: args[0], startMs: start };
|
||||||
|
metrics.reads.push(read);
|
||||||
|
request.addEventListener("success", () => {
|
||||||
|
read.requestMs = performance.now() - start;
|
||||||
|
read.entries = method === "getAllKeys" ? 0 : method === "get" ? Number(request.result != null) : request.result.length;
|
||||||
|
});
|
||||||
|
request.addEventListener("error", () => { read.error = request.error?.message; });
|
||||||
|
this.transaction.addEventListener("abort", () => { read.error = this.transaction.error?.message; });
|
||||||
|
this.transaction.addEventListener("complete", () => {
|
||||||
|
read.transactionMs = performance.now() - start;
|
||||||
|
checkReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) metrics.longTasks.push({ startMs: entry.startTime, durationMs: entry.duration });
|
||||||
|
}).observe({ type: "longtask", buffered: true });
|
||||||
|
}, expectedCards);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await preview({ configFile: false, root: "website", preview: { host: "127.0.0.1", port: 4173, strictPort: true } });
|
||||||
|
try {
|
||||||
|
await mkdir(".local/performance", { recursive: true });
|
||||||
|
const report = { date: new Date().toISOString(), platform: platform(), cpu: cpus()[0]?.model, repetitions, scenarios: [] };
|
||||||
|
for (const scenario of scenarios.filter((scenario) => !requested.length || requested.includes(scenario.name))) {
|
||||||
|
const profilePath = await mkdtemp(".local/performance/profile-");
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await chromium.launchPersistentContext(profilePath, { headless: true, baseURL: "http://127.0.0.1:4173", viewport: { width: 1440, height: 1000 } });
|
||||||
|
report.browser = context.browser().version();
|
||||||
|
const seedPage = await context.newPage();
|
||||||
|
const dataset = await seed(seedPage, scenario);
|
||||||
|
console.log(`Seeded ${scenario.name}: ${dataset.imageCount} images`);
|
||||||
|
await seedPage.close();
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await instrument(page, scenario.toolboxes);
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send("Performance.enable");
|
||||||
|
const samples = [];
|
||||||
|
let previousTimeOrigin;
|
||||||
|
// Une navigation de chauffe exclut le premier chargement des bundles des cinq mesures.
|
||||||
|
for (let iteration = 0; iteration <= repetitions; iteration += 1) {
|
||||||
|
if (iteration === 0) await page.goto("/#/toolboxes");
|
||||||
|
else await page.reload();
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(() => window.toolboxProfile?.readyMs != null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(scenario.name, iteration, errors, JSON.stringify(await page.evaluate(() => ({
|
||||||
|
metrics: window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length,
|
||||||
|
text: document.body.innerText
|
||||||
|
})), null, 2));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const sample = await page.evaluate(() => ({ ...window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length }));
|
||||||
|
assert.notEqual(sample.timeOrigin, previousTimeOrigin, "Every sample must load a new document");
|
||||||
|
previousTimeOrigin = sample.timeOrigin;
|
||||||
|
assert.equal(sample.reads.reduce((sum, read) => sum + read.entries, 0), scenario.toolboxes, "Only memo values should load before opening a toolbox");
|
||||||
|
assert.ok(sample.reads.every((read) => read.key == null || !String(read.key).endsWith(":images")), "Closed image modules must stay on disk");
|
||||||
|
assert.equal(sample.cards, scenario.toolboxes);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await cdp.send("HeapProfiler.collectGarbage");
|
||||||
|
const { metrics } = await cdp.send("Performance.getMetrics");
|
||||||
|
sample.jsHeapBytes = metrics.find((metric) => metric.name === "JSHeapUsedSize").value;
|
||||||
|
sample.longTasks = sample.longTasks.filter((task) => task.startMs < sample.readyMs);
|
||||||
|
if (iteration > 0) samples.push(sample);
|
||||||
|
}
|
||||||
|
const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
||||||
|
const summary = {
|
||||||
|
readyMedianMs: median(samples.map((sample) => sample.readyMs)),
|
||||||
|
readyMaxMs: Math.max(...samples.map((sample) => sample.readyMs)),
|
||||||
|
readMedianMs: median(samples.map((sample) => Math.max(...sample.reads.map((read) => read.startMs + read.transactionMs)) - Math.min(...sample.reads.map((read) => read.startMs)))),
|
||||||
|
heapMedianMiB: median(samples.map((sample) => sample.jsHeapBytes / 1024 ** 2)),
|
||||||
|
longestTaskMs: Math.max(0, ...samples.flatMap((sample) => sample.longTasks.map((task) => task.durationMs)))
|
||||||
|
};
|
||||||
|
report.scenarios.push({ ...scenario, ...dataset, summary, samples });
|
||||||
|
console.log(scenario.name, JSON.stringify({ mediaMiB: dataset.mediaBytes / 1024 ** 2, ...summary }));
|
||||||
|
} finally {
|
||||||
|
await context?.close();
|
||||||
|
await rm(profilePath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFile(".local/performance/toolbox-loading.json", `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
console.log("Report: .local/performance/toolbox-loading.json");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve()));
|
||||||
|
}
|
||||||
90
server.mjs
Normal file
90
server.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Rôle : sert l'application Vite construite ou les sources publiques en local.
|
||||||
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||||
|
import { loadProjectEnv } from "./scripts/load-project-env.mjs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { extname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||||
|
const websiteRoot = join(projectRoot, "website");
|
||||||
|
const distRoot = join(websiteRoot, "dist");
|
||||||
|
const root = existsSync(distRoot) ? distRoot : websiteRoot;
|
||||||
|
const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public");
|
||||||
|
|
||||||
|
loadProjectEnv();
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 5173);
|
||||||
|
|
||||||
|
const types = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp"
|
||||||
|
};
|
||||||
|
|
||||||
|
function isInsideRoot(parent, file) {
|
||||||
|
const relativePath = relative(parent, file);
|
||||||
|
return Boolean(relativePath) && !relativePath.startsWith("..") && !relativePath.startsWith("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIfReadable(file) {
|
||||||
|
return isInsideRoot(root, file) && existsSync(file) && statSync(file).isFile() ? file : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(url) {
|
||||||
|
let requested;
|
||||||
|
try {
|
||||||
|
requested = decodeURIComponent(new URL(url, "http://localhost").pathname);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = fileIfReadable(resolve(root, `.${requested}`));
|
||||||
|
if (candidate) return candidate;
|
||||||
|
|
||||||
|
const publicCandidate = fileIfReadable(resolve(publicRoot, `.${requested}`));
|
||||||
|
if (publicCandidate && isInsideRoot(publicRoot, publicCandidate)) return publicCandidate;
|
||||||
|
|
||||||
|
return extname(requested) ? null : join(root, "index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const file = resolvePath(req.url || "/");
|
||||||
|
if (!file || !existsSync(file)) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = extname(file);
|
||||||
|
const cacheControl = extension === ".html"
|
||||||
|
? "no-store"
|
||||||
|
: extension === ".json"
|
||||||
|
? "no-cache"
|
||||||
|
: "public, max-age=31536000, immutable";
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": types[extname(file)] || "application/octet-stream",
|
||||||
|
"Cache-Control": cacheControl
|
||||||
|
});
|
||||||
|
createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (error) => {
|
||||||
|
if (error.code === "EADDRINUSE") {
|
||||||
|
console.error(`Port ${port} is already in use. Set PORT to another value.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`Sokko G dev server running at http://localhost:${port}`);
|
||||||
|
});
|
||||||
9
tests/AGENTS.md
Normal file
9
tests/AGENTS.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
Cette zone contient les tests et helpers de validation.
|
||||||
|
|
||||||
|
## Validations de données
|
||||||
|
|
||||||
|
Quand `website/public/data/site.json`, `website/public/data/<gameId>/...` ou les déclarations de toolboxes changent, vérifier si `helpers/data-validation.mjs` doit être mis à jour.
|
||||||
|
|
||||||
|
Les validations doivent rester strictes : `site.json` est la source de vérité et `npm run check` doit échouer si le contenu requis est invalide.
|
||||||
133
tests/aniimo.test.mjs
Normal file
133
tests/aniimo.test.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// Rôle : vérifie les données Aniimo et le filtrage des variantes avant regroupement.
|
||||||
|
import { readFile, access } from "node:fs/promises";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createAniimoFilters, filterAniimoVariants, groupAniimoVariants, prepareAniimoData } from "../website/src/features/games/aniimo/utils.js";
|
||||||
|
import { loadAniimoData } from "../website/src/features/games/loaders.js";
|
||||||
|
import { validateAniimoData } from "./helpers/data-validation.mjs";
|
||||||
|
|
||||||
|
const readJson = async (path) => JSON.parse(await readFile(`website/public/data/${path}`, "utf8"));
|
||||||
|
const [aniimos, fr, en] = await Promise.all([readJson("aniimo/aniimos.json"), readJson("aniimo/i18n/fr.json"), readJson("aniimo/i18n/en.json")]);
|
||||||
|
const data = prepareAniimoData(aniimos, fr, en);
|
||||||
|
|
||||||
|
test("Aniimo attributes have valid stats and matching totals", () => {
|
||||||
|
validateAniimoData(aniimos, [fr, en]);
|
||||||
|
const invalid = structuredClone(aniimos[0]);
|
||||||
|
invalid.variations[0].attributes.total++;
|
||||||
|
assert.throws(() => validateAniimoData([invalid], [fr, en]), /Stats total/);
|
||||||
|
invalid.variations[0].attributes.stats.hp = -1;
|
||||||
|
assert.throws(() => validateAniimoData([invalid], [fr, en]), /nonnegative integers/);
|
||||||
|
});
|
||||||
|
const filter = (overrides, variants = data.variants) => filterAniimoVariants(variants, { ...createAniimoFilters(), ...overrides });
|
||||||
|
|
||||||
|
test("Aniimo data has valid variants, translations, portraits and icons", async () => {
|
||||||
|
validateAniimoData(aniimos, [fr, en]);
|
||||||
|
const game = (await readJson("games.json")).games.find((game) => game.id === "aniimo");
|
||||||
|
assert.ok(game);
|
||||||
|
assert.equal(game.images.aniimosCover, "/static/img/aniimo/emberpup__basic_form.png");
|
||||||
|
const assets = new Set([game.images.cardCover, game.images.heroBg, game.images.aniimosCover, ...data.variants.map((variant) => variant.image)]);
|
||||||
|
for (const option of Object.values(data.filterOptions).flat()) assets.add(`/static/img/aniimo/${option}.png`);
|
||||||
|
await Promise.all([...assets].map((asset) => access(`website/public${asset}`)));
|
||||||
|
for (const variant of data.variants) {
|
||||||
|
const png = await readFile(`website/public${variant.image}`);
|
||||||
|
assert.equal(png.readUInt32BE(16), 512);
|
||||||
|
assert.equal(png.readUInt32BE(20), 840);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validation rejects duplicate forms, missing translations and invalid levels", () => {
|
||||||
|
const entry = structuredClone(aniimos[0]);
|
||||||
|
entry.variations.push(entry.variations[0]);
|
||||||
|
assert.throws(() => validateAniimoData([entry], [fr, en]), /Duplicate form/);
|
||||||
|
assert.throws(() => validateAniimoData(aniimos, [{}]), /must be a string/);
|
||||||
|
for (const level of [0, 5, 1.5, "3"]) {
|
||||||
|
const invalid = structuredClone(aniimos[0]);
|
||||||
|
invalid.variations[0].homeland_abilities[0].level = level;
|
||||||
|
assert.throws(() => validateAniimoData([invalid], [fr, en]), /integer between 1 and 4/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("name searches accept French, English, accents and case", () => {
|
||||||
|
assert.deepEqual(filter({ name: " LOUFEUTEAU " }).map((v) => v.key), filter({ name: "Emberpup" }).map((v) => v.key));
|
||||||
|
assert.ok(filter({ name: "celestia" }).some((variant) => variant.name === "celestis"));
|
||||||
|
assert.ok(filter({ name: "pomoeuf" }).some((variant) => variant.name === "pomegg"));
|
||||||
|
assert.deepEqual(filter({ name: "inexistant-xyz" }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Prismana-only filtering combines with other criteria before grouping", () => {
|
||||||
|
assert.equal(createAniimoFilters().prismanaOnly, false);
|
||||||
|
const expected = data.variants.filter((variant) => variant.form === "prismana_form");
|
||||||
|
assert.ok(expected.length > 0);
|
||||||
|
assert.deepEqual(filter({ prismanaOnly: true }), expected);
|
||||||
|
const variant = expected[0];
|
||||||
|
const criteria = { name: variant.name, elements: variant.elements, roles: [variant.role], abilities: Object.fromEntries(variant.homeland_abilities.map(({ type, level }) => [type, level])) };
|
||||||
|
assert.deepEqual(filter({ ...criteria, prismanaOnly: true }), [variant]);
|
||||||
|
assert.equal(groupAniimoVariants(expected).length, expected.length);
|
||||||
|
assert.deepEqual(filter({ prismanaOnly: true, name: "emberpup" }), []);
|
||||||
|
assert.equal(filter({ prismanaOnly: false }).length, data.variants.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Prismana remains the final variation in data and grouped results", () => {
|
||||||
|
for (const group of groupAniimoVariants(data.variants)) {
|
||||||
|
if (group.some((variant) => variant.form === "prismana_form")) assert.equal(group.at(-1).form, "prismana_form");
|
||||||
|
}
|
||||||
|
const entry = structuredClone(aniimos.find((aniimo) => aniimo.variations.length > 1 && aniimo.variations.at(-1).form === "prismana_form"));
|
||||||
|
entry.variations.unshift(entry.variations.pop());
|
||||||
|
assert.throws(() => validateAniimoData([entry], [fr, en]), /Prismana must be last/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = prepareAniimoData([{
|
||||||
|
id: "001", name: "example", elements: ["rock", "water"], role: "support",
|
||||||
|
variations: [
|
||||||
|
{ form: "rock_form", elements: ["rock"], role: "dps", homeland_abilities: [{ type: "carry", level: 3 }] },
|
||||||
|
{ form: "water_form", elements: ["water"], role: "heal", homeland_abilities: [{ type: "crafting", level: 2 }] },
|
||||||
|
{ form: "both_form", elements: ["rock", "water"], role: "dps", homeland_abilities: [{ type: "carry", level: 4 }, { type: "crafting", level: 1 }] }
|
||||||
|
]
|
||||||
|
}], {}, {}).variants;
|
||||||
|
|
||||||
|
test("element AND never borrows elements or roles from the parent or sibling variants", () => {
|
||||||
|
assert.deepEqual(filter({ elements: ["rock", "water"] }, fixture).map((v) => v.form), ["both_form"]);
|
||||||
|
assert.equal(filter({ elements: ["rock", "water"], elementsLogic: "or" }, fixture).length, 3);
|
||||||
|
assert.equal(filter({ roles: ["support"] }, fixture).length, 0);
|
||||||
|
assert.equal(filter({ roles: ["dps", "heal"] }, fixture).length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ability thresholds remain independent, inclusive and constrained to a single variant", () => {
|
||||||
|
assert.equal(filter({ abilities: { carry: 3, crafting: 2 } }, fixture).length, 0);
|
||||||
|
assert.deepEqual(filter({ abilities: { carry: 3, crafting: 1 } }, fixture).map((v) => v.form), ["both_form"]);
|
||||||
|
assert.deepEqual(filter({ abilities: { carry: 3 }, abilityExact: true }, fixture).map((v) => v.form), ["rock_form"]);
|
||||||
|
assert.deepEqual(filter({ abilities: { carry: 4 }, abilityExact: true }, fixture).map((v) => v.form), ["both_form"]);
|
||||||
|
assert.equal(filter({ abilities: { carry: 2, crafting: 1 }, abilityExact: true }, fixture).length, 0);
|
||||||
|
assert.equal(filter({ elements: ["rock"], roles: ["heal"], abilities: { carry: 1, crafting: 1 } }, fixture).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grouping preserves order and contains only variants that pass all filters", () => {
|
||||||
|
const visible = filter({ elements: ["water"] }, fixture);
|
||||||
|
const groups = groupAniimoVariants(visible);
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.deepEqual(groups[0].map((v) => v.form), ["water_form", "both_form"]);
|
||||||
|
assert.deepEqual(groupAniimoVariants([]), []);
|
||||||
|
assert.equal(groupAniimoVariants(data.variants).length, aniimos.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loader passes cancellation to all requests and reports failed responses", async (context) => {
|
||||||
|
const calls = [];
|
||||||
|
const controller = new AbortController();
|
||||||
|
const listIndex = await readJson("aniimo/lists/index.json");
|
||||||
|
const regions = await readJson("aniimo/lists/regions.json");
|
||||||
|
const bodies = [aniimos, fr, en, listIndex, regions];
|
||||||
|
context.mock.method(globalThis, "fetch", async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
const body = bodies[calls.length - 1];
|
||||||
|
return { ok: true, json: async () => body };
|
||||||
|
});
|
||||||
|
const result = await loadAniimoData(controller.signal);
|
||||||
|
assert.equal(result.variants.length, data.variants.length);
|
||||||
|
assert.equal(result.lists.length, 1);
|
||||||
|
assert.equal(result.lists[0].id, "regions");
|
||||||
|
assert.equal(result.lists[0].itemCount, 14);
|
||||||
|
assert.equal(calls.length, 5);
|
||||||
|
assert.ok(calls.every((call) => call.options.signal === controller.signal));
|
||||||
|
globalThis.fetch.mock.mockImplementation(async () => ({ ok: false }));
|
||||||
|
await assert.rejects(loadAniimoData(controller.signal), /indisponibles/);
|
||||||
|
});
|
||||||
160
tests/auto-backup.test.mjs
Normal file
160
tests/auto-backup.test.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// Rôle : vérifie les règles de nommage, d’écriture et de rétention des sauvegardes locales.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
backupContentsEqual,
|
||||||
|
getBackupContentSignature,
|
||||||
|
getBackupFileName,
|
||||||
|
getLocalDateKey,
|
||||||
|
hasBackupDataChanged,
|
||||||
|
isFileSystemAccessSupported,
|
||||||
|
normalizeAutoBackupConfig,
|
||||||
|
saveBackup
|
||||||
|
} from "../website/src/features/toolboxes/storage/autoBackup.js";
|
||||||
|
|
||||||
|
class FakeDirectory {
|
||||||
|
constructor(names = []) {
|
||||||
|
this.files = new Map(names.map((name) => [name, "old"]));
|
||||||
|
this.directories = new Set();
|
||||||
|
this.permission = "granted";
|
||||||
|
this.failCleanup = false;
|
||||||
|
this.failWrite = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryPermission() { return this.permission; }
|
||||||
|
|
||||||
|
async *values() {
|
||||||
|
for (const name of this.files.keys()) yield { kind: "file", name };
|
||||||
|
for (const name of this.directories) yield { kind: "directory", name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileHandle(name, { create = false } = {}) {
|
||||||
|
if (create && !this.files.has(name)) this.files.set(name, "");
|
||||||
|
const directory = this;
|
||||||
|
return {
|
||||||
|
async getFile() {
|
||||||
|
return { text: async () => directory.files.get(name) || "" };
|
||||||
|
},
|
||||||
|
async createWritable() {
|
||||||
|
if (directory.failWrite) throw new Error("write failed");
|
||||||
|
return {
|
||||||
|
async write(contents) { directory.files.set(name, contents); },
|
||||||
|
async close() {},
|
||||||
|
async abort() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeEntry(name) {
|
||||||
|
if (this.failCleanup) throw new Error("cleanup failed");
|
||||||
|
this.files.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("feature detection does not rely on the user agent", () => {
|
||||||
|
assert.equal(isFileSystemAccessSupported({ showDirectoryPicker() {} }), true);
|
||||||
|
assert.equal(isFileSystemAccessSupported({}), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy enabled configurations start dirty while explicit backup state is preserved", () => {
|
||||||
|
assert.equal(normalizeAutoBackupConfig().hasUnsavedChanges, false);
|
||||||
|
assert.equal(normalizeAutoBackupConfig({ enabled: true, directoryHandle: {} }).hasUnsavedChanges, true);
|
||||||
|
assert.equal(normalizeAutoBackupConfig({ enabled: true, directoryHandle: {}, hasUnsavedChanges: false }).hasUnsavedChanges, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("backup names use the browser local date", () => {
|
||||||
|
const date = new Date(2026, 8, 24, 0, 5);
|
||||||
|
assert.equal(getLocalDateKey(date), "2026-09-24");
|
||||||
|
assert.equal(getBackupFileName(date), "sokkog-global-2026-09-24.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automatic backup skips an existing day and creates the next day", async () => {
|
||||||
|
const directory = new FakeDirectory(["sokkog-global-2026-09-23.json"]);
|
||||||
|
let generated = 0;
|
||||||
|
const generateJson = async () => { generated += 1; return "{}"; };
|
||||||
|
const first = await saveBackup({ directoryHandle: directory, generateJson, date: new Date(2026, 8, 23) });
|
||||||
|
const second = await saveBackup({ directoryHandle: directory, generateJson, date: new Date(2026, 8, 24) });
|
||||||
|
assert.equal(first.created, false);
|
||||||
|
assert.equal(second.created, true);
|
||||||
|
assert.equal(generated, 1);
|
||||||
|
assert.ok(directory.files.has("sokkog-global-2026-09-24.json"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automatic backup skips a new day when the export is unchanged", async () => {
|
||||||
|
const previous = JSON.stringify({ version: 1, exportedAt: "2026-09-23T08:00:00.000Z", toolboxes: [], modules: {}, links: {} });
|
||||||
|
const directory = new FakeDirectory(["sokkog-global-2026-09-23.json"]);
|
||||||
|
directory.files.set("sokkog-global-2026-09-23.json", previous);
|
||||||
|
const current = JSON.stringify({ version: 1, exportedAt: "2026-09-24T08:00:00.000Z", toolboxes: [], modules: {}, links: {} });
|
||||||
|
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => current, date: new Date(2026, 8, 24) });
|
||||||
|
assert.equal(backupContentsEqual(previous, current), true);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.created, false);
|
||||||
|
assert.equal(result.unchanged, true);
|
||||||
|
assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content signatures ignore the export timestamp and detect toolbox changes", async () => {
|
||||||
|
const previous = JSON.stringify({ version: 1, exportedAt: "2026-09-23T08:00:00.000Z", toolboxes: [{ id: "one", name: "Avant" }], modules: {}, links: {} });
|
||||||
|
const sameData = JSON.stringify({ version: 1, exportedAt: "2026-09-24T08:00:00.000Z", toolboxes: [{ id: "one", name: "Avant" }], modules: {}, links: {} });
|
||||||
|
const changedData = JSON.stringify({ version: 1, exportedAt: "2026-09-24T08:00:00.000Z", toolboxes: [{ id: "one", name: "Après" }], modules: {}, links: {} });
|
||||||
|
const previousSignature = await getBackupContentSignature(previous, {});
|
||||||
|
const sameSignature = await getBackupContentSignature(sameData, {});
|
||||||
|
const changedSignature = await getBackupContentSignature(changedData, {});
|
||||||
|
|
||||||
|
assert.equal(previousSignature, sameSignature);
|
||||||
|
assert.equal(hasBackupDataChanged({ lastBackupSignature: previousSignature, currentSignature: sameSignature }), false);
|
||||||
|
assert.equal(hasBackupDataChanged({ lastBackupSignature: previousSignature, currentSignature: changedSignature }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manual backup replaces the day and retains ten non-consecutive versions", async () => {
|
||||||
|
const names = [
|
||||||
|
"sokkog-global-2024-01-01.json", "sokkog-global-2024-06-01.json", "sokkog-global-2025-01-01.json",
|
||||||
|
"sokkog-global-2025-03-01.json", "sokkog-global-2025-09-01.json", "sokkog-global-2026-01-01.json",
|
||||||
|
"sokkog-global-2026-03-01.json", "sokkog-global-2026-06-01.json", "sokkog-global-2026-07-01.json",
|
||||||
|
"sokkog-global-2026-08-01.json", "sokkog-global-2026-09-24.json", "notes.json"
|
||||||
|
];
|
||||||
|
const directory = new FakeDirectory(names);
|
||||||
|
const result = await saveBackup({ directoryHandle: directory, force: true, date: new Date(2026, 8, 24), generateJson: async () => "new" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(directory.files.get("sokkog-global-2026-09-24.json"), "new");
|
||||||
|
assert.equal([...directory.files.keys()].filter((name) => /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/.test(name)).length, 10);
|
||||||
|
assert.equal(directory.files.get("notes.json"), "old");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing permission never opens a prompt or starts an export", async () => {
|
||||||
|
for (const permission of ["prompt", "denied"]) {
|
||||||
|
const directory = new FakeDirectory();
|
||||||
|
directory.permission = permission;
|
||||||
|
directory.requestPermission = () => { throw new Error("Permission popup must require user interaction"); };
|
||||||
|
let generated = false;
|
||||||
|
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => { generated = true; return "{}"; } });
|
||||||
|
assert.equal(result.reason, "permission");
|
||||||
|
assert.equal(result.permission, permission);
|
||||||
|
assert.equal(generated, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleanup failure does not invalidate a successfully written backup", async () => {
|
||||||
|
const directory = new FakeDirectory(Array.from({ length: 10 }, (_, index) => `sokkog-global-2020-01-${String(index + 1).padStart(2, "0")}.json`));
|
||||||
|
directory.failCleanup = true;
|
||||||
|
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => "{}", date: new Date(2026, 8, 24) });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.match(result.cleanupError, /cleanup failed/);
|
||||||
|
assert.equal(directory.files.get("sokkog-global-2026-09-24.json"), "{}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed write does not leave a newly created backup", async () => {
|
||||||
|
const directory = new FakeDirectory();
|
||||||
|
directory.failWrite = true;
|
||||||
|
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => "{}", date: new Date(2026, 8, 24) });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export failure does not create a partial backup", async () => {
|
||||||
|
const directory = new FakeDirectory();
|
||||||
|
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => { throw new Error("export failed"); }, date: new Date(2026, 8, 24) });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false);
|
||||||
|
});
|
||||||
135
tests/data-validation.test.mjs
Normal file
135
tests/data-validation.test.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// Rôle : lance les validations structurelles des données publiques du site.
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
validateDiablo4Affixes,
|
||||||
|
validateEndemicData,
|
||||||
|
validateLibraryData,
|
||||||
|
validateMonsterData,
|
||||||
|
validateSiteContent,
|
||||||
|
validateTranslationKeys
|
||||||
|
} from "./helpers/data-validation.mjs";
|
||||||
|
import { compactModuleDataForStorage, normalizeToolbox } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
|
test("site content json is complete", async () => {
|
||||||
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
validateSiteContent(site);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("library examples are available", async () => {
|
||||||
|
const library = JSON.parse(await readFile("website/public/data/library.json", "utf8"));
|
||||||
|
validateLibraryData(library);
|
||||||
|
const memosModule = library.toolbox.modules.find((module) => module.type === "memos");
|
||||||
|
const notepadModule = library.toolbox.modules.find((module) => module.type === "notepad");
|
||||||
|
assert.equal(memosModule?.title, "Mémos");
|
||||||
|
assert.equal(notepadModule?.title, "Bloc Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("library toolbox export is import-compatible", async () => {
|
||||||
|
const library = JSON.parse(await readFile("website/public/data/library.json", "utf8"));
|
||||||
|
const payload = JSON.parse(await readFile("website/public/data/library-toolbox-export.json", "utf8"));
|
||||||
|
const toolbox = normalizeToolbox(payload.toolbox);
|
||||||
|
const moduleIds = new Set(toolbox.modules.map((module) => module.id));
|
||||||
|
|
||||||
|
assert.equal(toolbox.name, "Librairie");
|
||||||
|
assert.equal(toolbox.modules.length, library.toolbox.modules.length);
|
||||||
|
assert.deepEqual(toolbox.modules.map((module) => module.type), library.toolbox.modules.map((module) => module.type));
|
||||||
|
assert.deepEqual(new Set(Object.keys(payload.modules)), moduleIds);
|
||||||
|
|
||||||
|
toolbox.modules.forEach((module) => {
|
||||||
|
const exportedModule = payload.toolbox.modules.find((item) => item.id === module.id);
|
||||||
|
assert.ok(exportedModule, `module ${module.id} must be declared`);
|
||||||
|
assert.equal(exportedModule.controls, undefined, `${module.id} must not include library docs controls`);
|
||||||
|
assert.equal(exportedModule.features, undefined, `${module.id} must not include library docs features`);
|
||||||
|
assert.equal(exportedModule.importFormat, undefined, `${module.id} must not include library docs import format`);
|
||||||
|
assert.ok(compactModuleDataForStorage(module.type, payload.modules[module.id]), `${module.id} data must be compactable`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mhwilds data and assets are available", async () => {
|
||||||
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
||||||
|
const monsters = JSON.parse(await readFile("website/public/data/mhwilds/monsters.json", "utf8"));
|
||||||
|
const endemicLife = JSON.parse(await readFile("website/public/data/mhwilds/endemic_life.json", "utf8"));
|
||||||
|
const listIndex = JSON.parse(await readFile("website/public/data/mhwilds/lists/index.json", "utf8"));
|
||||||
|
const translations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/fr.json", "utf8"));
|
||||||
|
const enTranslations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/en.json", "utf8"));
|
||||||
|
const listingSource = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
||||||
|
|
||||||
|
assert.equal(site.brand.name, "Sokko G");
|
||||||
|
assert.equal(site.home.hero.title, "Sokko G");
|
||||||
|
assert.equal(site.gamesPage.title, "Jeux disponibles");
|
||||||
|
assert.match(site.toolboxes.summary, /espaces modulaires/);
|
||||||
|
assert.ok(site.toolboxes.storageHelp.items.length >= 3);
|
||||||
|
assert.equal(games.games[0].id, "mhwilds");
|
||||||
|
assert.equal(games.games[0].eyebrow, "Guide de jeu");
|
||||||
|
assert.match(games.games[0].images.cardCover, /games\/mhwilds\/card-cover\.png/);
|
||||||
|
assert.match(games.games[0].images.heroBg, /games\/mhwilds\/hero-bg\.png/);
|
||||||
|
assert.ok(monsters.monsters.length > 0);
|
||||||
|
assert.deepEqual(monsters.elements, ["fire", "water", "thunder", "ice", "dragon"]);
|
||||||
|
validateMonsterData(monsters.monsters);
|
||||||
|
assert.ok(Array.isArray(endemicLife.locations), "endemicLife.locations must be an array");
|
||||||
|
assert.ok(endemicLife.locations.length > 0, "endemicLife.locations must not be empty");
|
||||||
|
assert.ok(endemicLife.endemicLife.length > 0);
|
||||||
|
validateEndemicData(endemicLife);
|
||||||
|
assert.ok(Array.isArray(listIndex.lists), "mhwilds list index must expose lists");
|
||||||
|
assert.ok(listIndex.lists.length >= 1, "mhwilds list index must contain at least one list");
|
||||||
|
for (const list of listIndex.lists) {
|
||||||
|
assert.equal(typeof list.id, "string", "mhwilds list id must be a string");
|
||||||
|
assert.equal(typeof list.title, "string", "mhwilds list title must be a string");
|
||||||
|
assert.match(list.file, /\.json$/, "mhwilds list file must target a json file");
|
||||||
|
const listData = JSON.parse(await readFile(`website/public/data/mhwilds/lists/${list.file}`, "utf8"));
|
||||||
|
assert.equal(typeof listData.titre, "string", `${list.file}.titre must be a string`);
|
||||||
|
assert.ok(listData.titre.trim(), `${list.file}.titre must not be empty`);
|
||||||
|
for (const [category, items] of Object.entries(listData).filter(([key]) => key !== "titre")) {
|
||||||
|
assert.ok(Array.isArray(items), `mhwilds list category ${category} must be an array`);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
assert.equal(typeof item.nom, "string", `${list.file}.${category}[${index}].nom must be a string`);
|
||||||
|
assert.ok(item.nom.trim(), `${list.file}.${category}[${index}].nom must not be empty`);
|
||||||
|
if (item.description !== undefined) {
|
||||||
|
assert.equal(typeof item.description, "string", `${list.file}.${category}[${index}].description must be a string`);
|
||||||
|
}
|
||||||
|
if (item.quantite !== undefined) {
|
||||||
|
assert.ok(Number.isFinite(Number(item.quantite)), `${list.file}.${category}[${index}].quantite must be numeric`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateTranslationKeys(translations, "fr");
|
||||||
|
validateTranslationKeys(enTranslations, "en");
|
||||||
|
assert.equal(translations.monsters, "monstres");
|
||||||
|
assert.equal(translations.dark_hornet, "frelon clair-obscur");
|
||||||
|
assert.equal(translations["dark hornet"], undefined);
|
||||||
|
assert.match(listingSource, /function MhwildsListing/);
|
||||||
|
const monsterCardSource = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
|
||||||
|
assert.match(monsterCardSource, /monster-card/);
|
||||||
|
assert.match(monsterCardSource, /monster\.hitzones/);
|
||||||
|
await readFile("website/public/static/img/mhwilds/chatacabra.png");
|
||||||
|
await readFile("website/public/static/img/games/mhwilds/card-cover.png");
|
||||||
|
await readFile("website/public/static/img/games/mhwilds/hero-bg.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diablo4 data and route are available", async () => {
|
||||||
|
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
||||||
|
const affixes = JSON.parse(await readFile("website/public/data/diablo4/affixes_types.json", "utf8"));
|
||||||
|
const routeSource = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
|
||||||
|
const pageSource = await readFile("website/src/features/games/diablo4/Diablo4Page.jsx", "utf8");
|
||||||
|
const listingSource = await readFile("website/src/features/games/diablo4/Diablo4Listing.jsx", "utf8");
|
||||||
|
const diablo4Game = games.games.find((game) => game.id === "diablo4");
|
||||||
|
|
||||||
|
assert.ok(diablo4Game, "games.json must declare diablo4");
|
||||||
|
assert.match(diablo4Game.images.cardCover, /games\/diablo4\/card-cover\.png/);
|
||||||
|
assert.match(diablo4Game.images.heroBg, /games\/diablo4\/hero-bg\.png/);
|
||||||
|
validateDiablo4Affixes(affixes);
|
||||||
|
assert.match(routeSource, /game\.id === "diablo4"/);
|
||||||
|
assert.match(pageSource, /category === "affixes"/);
|
||||||
|
assert.match(listingSource, /diablo4\.filterOptions\.affixes/);
|
||||||
|
await readFile("website/public/static/img/games/diablo4/card-cover.png");
|
||||||
|
await readFile("website/public/static/img/games/diablo4/hero-bg.png");
|
||||||
|
await readFile("website/public/static/icons/axe.svg");
|
||||||
|
await readFile("website/public/static/icons/cube.svg");
|
||||||
|
await readFile("website/public/static/icons/fire.svg");
|
||||||
|
await readFile("website/public/static/icons/shield.svg");
|
||||||
|
await readFile("website/public/static/icons/wing.svg");
|
||||||
|
});
|
||||||
43
tests/deferred-module-data.test.mjs
Normal file
43
tests/deferred-module-data.test.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// Rôle : vérifie la frontière entre médias différés, données vides et écritures concurrentes.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { DEFERRED_MODULE_DATA as deferred, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "../website/src/features/toolboxes/storage/deferredModuleData.js";
|
||||||
|
import { createPersistenceQueue } from "../website/src/features/toolboxes/storage/persistenceQueue.js";
|
||||||
|
|
||||||
|
test("only images and annotations are deferred; alert and text tools remain available", () => {
|
||||||
|
assert.deepEqual(getDeferredModuleKeys([{ id: "box", modules: [
|
||||||
|
{ id: "image", type: "images" }, { id: "map", type: "imageAnnotation" },
|
||||||
|
{ id: "timer", type: "timer" }, { id: "calendar", type: "calendar" },
|
||||||
|
{ id: "memos", type: "memos" }, { id: "notepad", type: "notepad" }
|
||||||
|
] }]), ["box:image", "box:map"]);
|
||||||
|
assert.deepEqual(splitModuleData({ "box:image": deferred, "box:timer": { countdowns: [] } }), {
|
||||||
|
stored: { "box:timer": { countdowns: [] } }, deferred: ["box:image"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loading never replaces edits or resurrects deleted module data", () => {
|
||||||
|
const data = { pending: deferred, missing: deferred, edited: { images: ["new"] } };
|
||||||
|
const loaded = mergeLoadedModuleData(data, ["pending", "missing", "edited", "deleted"], [
|
||||||
|
{ key: "pending", data: { image: "loaded" } }, { key: "edited", data: "old" }, { key: "deleted", data: "old" }
|
||||||
|
]);
|
||||||
|
assert.deepEqual(loaded, { pending: { image: "loaded" }, edited: { images: ["new"] } });
|
||||||
|
assert.equal(data.pending, deferred);
|
||||||
|
assert.equal(data.missing, deferred);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failDelete of [false, true]) {
|
||||||
|
test(`a load queued during deletion follows its confirmed outcome (failure=${failDelete})`, async () => {
|
||||||
|
let finish;
|
||||||
|
const transaction = new Promise((resolve) => { finish = resolve; });
|
||||||
|
const queue = createPersistenceQueue({ media: deferred }, () => {});
|
||||||
|
const removal = queue.enqueue(() => ({}), async () => {
|
||||||
|
await transaction;
|
||||||
|
if (failDelete) throw new Error("abort");
|
||||||
|
});
|
||||||
|
const load = queue.enqueue((current) => mergeLoadedModuleData(current, ["media"], [{ key: "media", data: "kept" }]), () => {});
|
||||||
|
finish();
|
||||||
|
assert.equal(await removal, !failDelete);
|
||||||
|
assert.equal(await load, true);
|
||||||
|
assert.deepEqual(queue.getCurrent(), failDelete ? { media: "kept" } : {});
|
||||||
|
});
|
||||||
|
}
|
||||||
4
tests/fixtures/project-env.txt
vendored
Normal file
4
tests/fixtures/project-env.txt
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Rôle : fournit des variables non sensibles pour tester le chargement .env.
|
||||||
|
SOKKOG_ENV_FIXTURE_PORT=4321
|
||||||
|
SOKKOG_ENV_FIXTURE_QUOTED="value # inside"
|
||||||
|
SOKKOG_ENV_FIXTURE_COMMENT=value # outside
|
||||||
69
tests/guide-checklist.test.mjs
Normal file
69
tests/guide-checklist.test.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Rôle : vérifie les correspondances des guides et la validité des associations persistantes.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, toggleGuideChecklistRows, validGuideAssociations } from "../website/src/features/games/guideChecklist.js";
|
||||||
|
|
||||||
|
const variants = [{ key: "1:base", name: "emberpup", form: "base" }, { key: "1:mountain", name: "emberpup", form: "mountain" }];
|
||||||
|
const entries = aniimoChecklistEntries(variants, { emberpup: "Loufeuteau", base: "Forme de base", mountain: "Montagnes" }, { emberpup: "Emberpup", base: "Basic form", mountain: "Mountain form" });
|
||||||
|
|
||||||
|
test("full names normalize accents, case and whitespace without approximate matching", () => {
|
||||||
|
assert.equal(normalizeGuideLabel(" ŒUF Écarlate \n"), "oeuf ecarlate");
|
||||||
|
const result = matchGuideChecklist([{ key: "egg", labels: ["Œuf écarlate"] }], { items: [
|
||||||
|
{ label: "OEUF ECARLATE", qtyCurrent: 1 }, { label: "Œuf", qtyCurrent: 0 }
|
||||||
|
] });
|
||||||
|
assert.equal(result.count, 1);
|
||||||
|
assert.ok(result.completed.has("egg"));
|
||||||
|
assert.equal(matchGuideChecklist(entries, { items: [{ label: "Loufeu", qtyCurrent: 1 }] }).count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("species completion covers forms, but a specific French or English row takes priority", () => {
|
||||||
|
const data = { sections: [{ items: [
|
||||||
|
{ label: "EMBERPUP", qtyCurrent: 1 },
|
||||||
|
{ label: "Loufeuteau — forme de base", qtyCurrent: 0 }
|
||||||
|
] }] };
|
||||||
|
const result = matchGuideChecklist(entries, data);
|
||||||
|
assert.equal(result.count, 2);
|
||||||
|
assert.deepEqual([...result.completed], ["1:mountain"]);
|
||||||
|
const english = matchGuideChecklist(entries, { items: [{ label: "Emberpup — Basic form", qtyCurrent: 1 }] });
|
||||||
|
assert.equal(english.count, 1);
|
||||||
|
assert.deepEqual([...english.completed], ["1:base"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all duplicate lines must reach their own target, including translated aliases", () => {
|
||||||
|
const data = { items: [
|
||||||
|
{ label: "Loufeuteau", qtyTarget: 3, qtyCurrent: 2 },
|
||||||
|
{ label: "Emberpup", qtyCurrent: 1 },
|
||||||
|
{ label: "Unrelated", qtyCurrent: 1 }
|
||||||
|
] };
|
||||||
|
assert.equal(matchGuideChecklist(entries, data).completed.size, 0);
|
||||||
|
data.items[0].qtyCurrent = 3;
|
||||||
|
assert.equal(matchGuideChecklist(entries, data).completed.size, 2);
|
||||||
|
data.items.push({ label: "Loufeuteau" });
|
||||||
|
assert.equal(matchGuideChecklist(entries, data).completed.size, 0);
|
||||||
|
assert.equal(matchGuideChecklist(entries, {}).count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("card toggles update every exact matching row to its target or zero", () => {
|
||||||
|
const data = { sections: [{ id: "s1", items: [
|
||||||
|
{ id: "a", label: "Loufeuteau", qtyTarget: 3, qtyCurrent: 1 },
|
||||||
|
{ id: "b", label: "Emberpup", qtyTarget: 2, qtyCurrent: 0 },
|
||||||
|
{ id: "c", label: "Unrelated", qtyCurrent: 1 }
|
||||||
|
] }] };
|
||||||
|
const match = matchGuideChecklist(entries, data);
|
||||||
|
assert.deepEqual(match.rowIds.get("1:base"), ["a", "b"]);
|
||||||
|
const completed = toggleGuideChecklistRows(data, match.rowIds.get("1:base"), true);
|
||||||
|
assert.deepEqual(completed.sections[0].items.map((item) => item.qtyCurrent), [3, 2, 1]);
|
||||||
|
const cleared = toggleGuideChecklistRows(completed, match.rowIds.get("1:base"), false);
|
||||||
|
assert.deepEqual(cleared.sections[0].items.map((item) => item.qtyCurrent), [0, 0, 1]);
|
||||||
|
assert.equal(data.sections[0].items[0].qtyCurrent, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persistent associations require the current linked toolbox and an existing checklist", () => {
|
||||||
|
const toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] };
|
||||||
|
const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true };
|
||||||
|
const state = { "aniimo/aniimos": association, "mhwilds/monsters": { ...association, moduleId: "notes" }, unknown: association };
|
||||||
|
assert.deepEqual(validGuideAssociations(state, [toolbox], { aniimo: "tb", mhwilds: "tb" }), { "aniimo/aniimos": association });
|
||||||
|
assert.deepEqual(validGuideAssociations(state, [toolbox], { aniimo: "other" }), {});
|
||||||
|
assert.deepEqual(validGuideAssociations(state, [], { aniimo: "tb" }), {});
|
||||||
|
assert.deepEqual(validGuideAssociations({ "aniimo/aniimos": null }, [toolbox], { aniimo: "tb" }), {});
|
||||||
|
});
|
||||||
1353
tests/helpers/data-validation.mjs
Normal file
1353
tests/helpers/data-validation.mjs
Normal file
File diff suppressed because it is too large
Load diff
184
tests/persistence-queue.test.mjs
Normal file
184
tests/persistence-queue.test.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// Rôle : teste les écritures confirmées, échecs successifs et imports concurrents.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createPersistenceQueue } from "../website/src/features/toolboxes/storage/persistenceQueue.js";
|
||||||
|
import { useToolboxActions } from "../website/src/features/toolboxes/useToolboxActions.js";
|
||||||
|
import { applyModuleDataValue, removeModuleDataKeys } from "../website/src/features/toolboxes/storage/optimisticModuleData.js";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve, reject;
|
||||||
|
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["toolboxes", "links", "moduleData", "toastPosition"]) {
|
||||||
|
for (const outcomes of [[true, true], [true, false], [false, true], [false, false]]) {
|
||||||
|
test(`${field}: confirms only successful writes ${outcomes}`, async () => {
|
||||||
|
let disk = { [field]: "A" };
|
||||||
|
const errors = [];
|
||||||
|
const queue = createPersistenceQueue(disk, () => {});
|
||||||
|
const gates = [deferred(), deferred()];
|
||||||
|
let active = 0;
|
||||||
|
const write = (index) => queue.enqueue(
|
||||||
|
(current) => ({ ...current, [field]: index ? "C" : "B" }),
|
||||||
|
async (next) => {
|
||||||
|
assert.equal(++active, 1);
|
||||||
|
try { await gates[index].promise; disk = next; }
|
||||||
|
finally { active--; }
|
||||||
|
},
|
||||||
|
(error) => errors.push(error.message)
|
||||||
|
);
|
||||||
|
const first = write(0);
|
||||||
|
const second = write(1);
|
||||||
|
assert.equal(queue.getCurrent()[field], "C");
|
||||||
|
outcomes[0] ? gates[0].resolve() : gates[0].reject(new Error("first"));
|
||||||
|
assert.equal(await first, outcomes[0]);
|
||||||
|
assert.equal(queue.getCurrent()[field], "C");
|
||||||
|
outcomes[1] ? gates[1].resolve() : gates[1].reject(new Error("second"));
|
||||||
|
assert.equal(await second, outcomes[1]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), disk);
|
||||||
|
assert.equal(disk[field], outcomes[1] ? "C" : outcomes[0] ? "B" : "A");
|
||||||
|
assert.equal(errors.length, outcomes.filter((ok) => !ok).length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("failed snapshot cannot undo a later edit or reintroduce a failed earlier write", async () => {
|
||||||
|
const initial = { toolboxes: ["existing"], links: {}, moduleData: { note: "A" } };
|
||||||
|
let disk = initial;
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const gate = deferred();
|
||||||
|
const earlier = queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), () => gate.promise);
|
||||||
|
const snapshot = queue.enqueue(
|
||||||
|
(s) => ({ ...s, toolboxes: ["imported", ...s.toolboxes] }),
|
||||||
|
async () => { throw new Error("abort"); }
|
||||||
|
);
|
||||||
|
const edit = queue.enqueue((s) => ({ ...s, moduleData: { note: "C" } }), async (next) => { disk = next; });
|
||||||
|
gate.reject(new Error("quota"));
|
||||||
|
assert.deepEqual(await Promise.all([earlier, snapshot, edit]), [false, false, true]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), { ...initial, moduleData: { note: "C" } });
|
||||||
|
assert.deepEqual(disk, queue.getCurrent());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports merge after file reading and preserve edits queued during the transaction", async () => {
|
||||||
|
const initial = { toolboxes: [{ id: "existing", modules: [] }], links: {}, moduleData: { note: "A" } };
|
||||||
|
let disk = initial;
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const transaction = deferred();
|
||||||
|
const fileRead = deferred();
|
||||||
|
const notices = [];
|
||||||
|
const actions = useToolboxActions({
|
||||||
|
store: {
|
||||||
|
ready: true,
|
||||||
|
...initial,
|
||||||
|
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => {
|
||||||
|
await transaction.promise;
|
||||||
|
disk = next;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
notify: (value) => notices.push(value),
|
||||||
|
setConfirmModal: (value) => assert.fail(value.message)
|
||||||
|
});
|
||||||
|
const imported = actions.importToolbox({ text: () => fileRead.promise }, "game");
|
||||||
|
await queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), async (next) => { disk = next; });
|
||||||
|
fileRead.resolve(JSON.stringify({ toolbox: { id: "source", name: "Import", modules: [] }, modules: {} }));
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(notices.length, 0);
|
||||||
|
const edit = queue.enqueue((s) => ({ ...s, moduleData: { ...s.moduleData, note: "C" } }), async (next) => { disk = next; });
|
||||||
|
transaction.resolve();
|
||||||
|
const toolbox = await imported;
|
||||||
|
await edit;
|
||||||
|
assert.equal(disk.moduleData.note, "C");
|
||||||
|
assert.deepEqual(disk.toolboxes.map((t) => t.id), [toolbox.id, "existing"]);
|
||||||
|
assert.equal(disk.links.game, toolbox.id);
|
||||||
|
assert.deepEqual(queue.getCurrent(), disk);
|
||||||
|
assert.equal(notices.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed module removal restores the confirmed value after an earlier failed edit", async () => {
|
||||||
|
const initial = { moduleData: { note: { text: "A" }, other: { text: "Other" } } };
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const first = queue.enqueue(
|
||||||
|
(s) => ({ ...s, moduleData: applyModuleDataValue(s.moduleData, "note", { text: "B" }) }),
|
||||||
|
async () => { throw new Error("quota"); }
|
||||||
|
);
|
||||||
|
const second = queue.enqueue(
|
||||||
|
(s) => ({ ...s, moduleData: removeModuleDataKeys(s.moduleData, ["note"]) }),
|
||||||
|
async () => { throw new Error("abort"); }
|
||||||
|
);
|
||||||
|
assert.deepEqual(await Promise.all([first, second]), [false, false]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global imports started from the same render both survive", async () => {
|
||||||
|
const initial = { toolboxes: [], links: {}, moduleData: {} };
|
||||||
|
let disk = initial;
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const actions = useToolboxActions({
|
||||||
|
store: {
|
||||||
|
ready: true,
|
||||||
|
...initial,
|
||||||
|
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => { disk = next; })
|
||||||
|
},
|
||||||
|
notify() {},
|
||||||
|
setConfirmModal: (value) => assert.fail(value.message)
|
||||||
|
});
|
||||||
|
const file = (id) => ({ text: async () => JSON.stringify({
|
||||||
|
toolboxes: [{ id, name: id, modules: [{ id: "note", type: "notepad" }] }],
|
||||||
|
links: { [id]: id },
|
||||||
|
modules: { [`${id}:note`]: { text: id } }
|
||||||
|
}) });
|
||||||
|
assert.deepEqual(await Promise.all([
|
||||||
|
actions.importAllToolboxes(file("one")), actions.importAllToolboxes(file("two"))
|
||||||
|
]), [true, true]);
|
||||||
|
assert.equal(disk.toolboxes.length, 2);
|
||||||
|
assert.equal(Object.keys(disk.moduleData).length, 2);
|
||||||
|
assert.deepEqual(Object.keys(disk.links).sort(), ["one", "two"]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), disk);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("v2 imports reject more than five spaces before writing", async () => {
|
||||||
|
let writes = 0;
|
||||||
|
let errorMessage = "";
|
||||||
|
const actions = useToolboxActions({
|
||||||
|
store: {
|
||||||
|
ready: true,
|
||||||
|
persistToolboxSnapshot: () => { writes += 1; return Promise.resolve(true); }
|
||||||
|
},
|
||||||
|
notify() {},
|
||||||
|
setConfirmModal: (value) => { errorMessage = value.message; }
|
||||||
|
});
|
||||||
|
const result = await actions.importToolbox({ text: async () => JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
toolbox: {
|
||||||
|
id: "overflow",
|
||||||
|
name: "Overflow",
|
||||||
|
modules: [],
|
||||||
|
spaces: Array.from({ length: 6 }, (_, index) => ({ id: `space-${index}`, name: `Space ${index}`, moduleOrder: { one: [], two: [[], []] } }))
|
||||||
|
},
|
||||||
|
modules: {}
|
||||||
|
}) });
|
||||||
|
assert.equal(result, null);
|
||||||
|
assert.equal(writes, 0);
|
||||||
|
assert.match(errorMessage, /plus de 5 espaces/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed toolbox deletion preserves registry links and modules together", async () => {
|
||||||
|
const initial = {
|
||||||
|
toolboxes: [{ id: "existing", modules: [{ id: "note", type: "notepad" }] }],
|
||||||
|
links: { game: "existing" },
|
||||||
|
moduleData: { "existing:note": { text: "Keep" } }
|
||||||
|
};
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const actions = useToolboxActions({
|
||||||
|
store: {
|
||||||
|
ready: true,
|
||||||
|
...initial,
|
||||||
|
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async () => { throw new Error("abort"); })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const deleted = actions.deleteToolbox("existing");
|
||||||
|
assert.deepEqual(queue.getCurrent(), { toolboxes: [], links: {}, moduleData: {} });
|
||||||
|
assert.equal(await deleted, false);
|
||||||
|
assert.deepEqual(queue.getCurrent(), initial);
|
||||||
|
});
|
||||||
20
tests/project-env.test.mjs
Normal file
20
tests/project-env.test.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Rôle : vérifie le parsing .env, la priorité du processus et les fichiers facultatifs.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { loadProjectEnv } from "../scripts/load-project-env.mjs";
|
||||||
|
|
||||||
|
for (const existing of ["9999", ""]) {
|
||||||
|
test(`project env preserves an existing ${existing ? "nonempty" : "empty"} value`, (t) => {
|
||||||
|
const keys = ["SOKKOG_ENV_FIXTURE_PORT", "SOKKOG_ENV_FIXTURE_QUOTED", "SOKKOG_ENV_FIXTURE_COMMENT"];
|
||||||
|
const previous = keys.map((key) => process.env[key]);
|
||||||
|
t.after(() => keys.forEach((key, index) => {
|
||||||
|
if (previous[index] === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = previous[index];
|
||||||
|
}));
|
||||||
|
keys.forEach((key) => { delete process.env[key]; });
|
||||||
|
process.env.SOKKOG_ENV_FIXTURE_PORT = existing;
|
||||||
|
loadProjectEnv("tests/fixtures/project-env.txt");
|
||||||
|
loadProjectEnv("tests/fixtures/missing-env-file");
|
||||||
|
assert.deepEqual(keys.map((key) => process.env[key]), [existing, "value # inside", "value"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
90
tests/quota-refresh.test.mjs
Normal file
90
tests/quota-refresh.test.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Rôle : teste les rafales d'estimations, la concurrence et le nettoyage du quota.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createQuotaRefresh } from "../website/src/features/toolboxes/storage/quotaRefresh.js";
|
||||||
|
|
||||||
|
function setup(estimate) {
|
||||||
|
const timers = new Map();
|
||||||
|
const results = [];
|
||||||
|
let id = 0;
|
||||||
|
const refresh = createQuotaRefresh({
|
||||||
|
estimate,
|
||||||
|
onResult: (result) => results.push(result),
|
||||||
|
schedule: (callback) => { timers.set(++id, callback); return id; },
|
||||||
|
cancel: (key) => timers.delete(key)
|
||||||
|
});
|
||||||
|
function tick() {
|
||||||
|
assert.equal(timers.size, 1);
|
||||||
|
const [key, callback] = timers.entries().next().value;
|
||||||
|
timers.delete(key);
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
return { ...refresh, timers, results, tick };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("one hundred refresh requests trigger one estimate", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const refresh = setup(async () => { calls++; return { usage: 12 }; });
|
||||||
|
for (let i = 0; i < 100; i++) refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.deepEqual(refresh.results, [{ usage: 12 }]);
|
||||||
|
assert.equal(refresh.timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes during estimation schedule one follow-up and discard the stale result", async () => {
|
||||||
|
let finish;
|
||||||
|
let calls = 0;
|
||||||
|
const refresh = setup(() => {
|
||||||
|
calls++;
|
||||||
|
return new Promise((resolve) => { finish = resolve; });
|
||||||
|
});
|
||||||
|
refresh.request();
|
||||||
|
const first = refresh.tick();
|
||||||
|
for (let i = 0; i < 100; i++) refresh.request();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(refresh.timers.size, 0);
|
||||||
|
finish({ usage: 1 });
|
||||||
|
await first;
|
||||||
|
assert.deepEqual(refresh.results, []);
|
||||||
|
const second = refresh.tick();
|
||||||
|
finish({ usage: 2 });
|
||||||
|
await second;
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.deepEqual(refresh.results, [{ usage: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispose cancels scheduled work and ignores in-flight results", async () => {
|
||||||
|
let finish;
|
||||||
|
const refresh = setup(() => new Promise((resolve) => { finish = resolve; }));
|
||||||
|
refresh.request();
|
||||||
|
refresh.dispose();
|
||||||
|
refresh.request();
|
||||||
|
assert.equal(refresh.timers.size, 0);
|
||||||
|
const active = setup(() => new Promise((resolve) => { finish = resolve; }));
|
||||||
|
active.request();
|
||||||
|
const task = active.tick();
|
||||||
|
active.dispose();
|
||||||
|
finish({ usage: 5 });
|
||||||
|
await task;
|
||||||
|
assert.deepEqual(active.results, []);
|
||||||
|
assert.equal(active.timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed estimate preserves the last result and allows recovery", async () => {
|
||||||
|
let fail = false;
|
||||||
|
const refresh = setup(async () => {
|
||||||
|
if (fail) throw new Error("unavailable");
|
||||||
|
return { usage: 3 };
|
||||||
|
});
|
||||||
|
refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
fail = true;
|
||||||
|
refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
assert.deepEqual(refresh.results, [{ usage: 3 }]);
|
||||||
|
fail = false;
|
||||||
|
refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
assert.equal(refresh.results.length, 2);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue