@@ -1,8 +1,15 @@
// play.mjs -- joue une partie complète par l'interface, dans un vrai
// navigateur sans écran (Playwright), sur le site servi par l'image de
// production : dés, coups au toucher (validés, annul és) , coups de
// l'ordinateur, videau (avec sa confirmation), reprise
// après rechargement, aide, puis hors ligne (service worker).
// production : chargement (plateau dessiné avant le moteur), d és, coups au
// toucher (joués, annulés), coups de l'ordinateur, videau (avec sa
// confirmation), aucun coup possible, menu, nouvelle partie reprise,
// vibrations, plein écran (repli par rotation), paysage, reprise après
// rechargement, aide, thème sombre, puis hors ligne (service worker).
//
// Il vérifie en chemin les critères automatisables de la recette design
// (docs/identite/charte/recette-ux.md) et, dans Chromium, range les
// captures de la revue dans <dossier>/revision-ux/ : 412× 915, 915× 412 et
// 844× 320, thèmes clair et sombre, à six moments de la partie.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -12,6 +19,7 @@
// premier problème, ou si la page signale une erreur (dont une violation de
// la politique de sécurité du contenu).
import { mkdirSync } from "node:fs" ;
import { chromium , firefox } from "playwright" ;
const [ base , browserName , shots ] = process . argv . slice ( 2 ) ;
@@ -21,15 +29,30 @@ if (!base || !browserType || !shots) {
process . exit ( 2 ) ;
}
// La recette complète (six vues à chaque moment, captures de la revue) dans
// Chromium ; Firefox joue la même partie et vérifie la vue courante.
const fullReview = browserName === "chromium" ;
const reviewDir = ` ${ shots } /revision-ux ` ;
if ( fullReview ) {
mkdirSync ( reviewDir , { recursive : true } ) ;
}
const PORTRAIT = { width : 412 , height : 915 } ;
const VIEWS = [
[ 412 , 915 ] ,
[ 915 , 412 ] ,
[ 844 , 320 ] ,
] ;
const browser = await browserType . launch ( ) ;
const context = await browser . newContext ( {
viewport : { width : 360 , height : 740 } ,
deviceScaleFactor : 2 ,
viewport : PORTRAIT ,
deviceScaleFactor : 1 ,
locale : "fr-FR" ,
} ) ;
// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur
// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la
// politique de sécurité du contenu, chaque violation signalée par la page.
// (Web Worker). Pour la recette : chaque violation de la politique de
// sécurité du contenu, chaque boîte native (confirm, alert, prompt) et
// chaque vibration demandée.
await context . addInitScript ( ( ) => {
window . _ _engine = { sent : 0 , received : 0 , last : null } ;
const post = Worker . prototype . postMessage ;
@@ -46,10 +69,29 @@ await context.addInitScript(() => {
document . addEventListener ( "securitypolicyviolation" , ( e ) =>
window . _ _csp . push ( ` ${ e . effectiveDirective } ${ e . blockedURI } ` ) ,
) ;
window . _ _dialogs = [ ] ;
for ( const name of [ "confirm" , "alert" , "prompt" ] ) {
window [ name ] = ( ... args ) => {
window . _ _dialogs . push ( ` ${ name } ( ${ args . join ( ", " ) } ) ` ) ;
return null ;
} ;
}
window . _ _vibrations = [ ] ;
Object . defineProperty ( Navigator . prototype , "vibrate" , {
configurable : true ,
value : ( pattern ) => {
window . _ _vibrations . push ( Array . isArray ( pattern ) ? pattern . join ( "," ) : String ( pattern ) ) ;
return true ;
} ,
} ) ;
} ) ;
const page = await context . newPage ( ) ;
const problems = [ ] ;
page . on ( "pageerror" , ( error ) => problems . push ( ` erreur de la page : ${ error . message } ` ) ) ;
page . on ( "dialog" , ( dialog ) => {
problems . push ( ` boîte native ouverte : ${ dialog . type ( ) } « ${ dialog . message ( ) } » ` ) ;
void dialog . dismiss ( ) ;
} ) ;
page . on ( "crash" , ( ) => {
console . error ( ` [ ${ browserName } ] la page a planté ` ) ;
process . exit ( 1 ) ;
@@ -64,12 +106,16 @@ page.on("console", (message) => {
} ) ;
const log = ( text ) => console . log ( ` [ ${ browserName } ] ${ text } ` ) ;
const shot = ( name ) => page . screenshot ( { path : ` ${ shots } / ${ browserName } - ${ name } .png ` } ) ;
const sleep = ( ms ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
/** Relève les violations de la CSP signalées par la page (avant chaque
* rechargement, qui les efface). */
* rechargement, qui les efface), et les boîtes natives . */
const violations = [ ] ;
const dialogs = [ ] ;
async function collectViolations ( ) {
violations . push ( ... ( await page . evaluate ( ( ) => window . _ _csp ) ) ) ;
const seen = await page . evaluate ( ( ) => ( { csp : window . _ _csp , dialogs : window . _ _dialogs } ) ) ;
violations . push ( ... seen . csp ) ;
dialogs . push ( ... seen . dialogs ) ;
}
function check ( condition , message ) {
@@ -87,6 +133,10 @@ function countMoves(journal, side) {
return journal . events . filter ( ( e ) => e . type === "MovePlayed" && e . side === side ) . length ;
}
async function vibrations ( ) {
return page . evaluate ( ( ) => [ ... window . _ _vibrations ] ) ;
}
/**
* Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de
* page.waitForFunction : Playwright y passe par eval, que la politique de
@@ -98,6 +148,10 @@ async function waitForPlayer() {
if ( document . body . classList . contains ( "busy" ) ) {
return null ;
}
const panel = document . getElementById ( "panel-veil" ) ;
if ( ! panel . hidden ) {
return panel . dataset . kind ;
}
const usable = ( id ) => {
const b = document . getElementById ( id ) ;
return ! b . hidden && ! b . disabled ;
@@ -112,7 +166,7 @@ async function waitForPlayer() {
if ( todo !== null ) {
return todo ;
}
await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) ) ;
await sleep ( 100 ) ;
}
await shot ( "bloque" ) . catch ( ( ) => { } ) ;
const screen = await page
@@ -131,6 +185,217 @@ async function waitForPlayer() {
throw new Error ( ` rien à faire pour le joueur depuis 60 s : ${ screen } ` ) ;
}
// ------------------------------------------------------- Recette design
/** Les boîtes des zones de l'écran de jeu en portrait (y et hauteur). */
async function zoneBoxes ( ) {
return page . evaluate ( ( ) => {
const box = ( selector ) => {
const r = document . querySelector ( selector ) . getBoundingClientRect ( ) ;
return [ Math . round ( r . x ) , Math . round ( r . y ) , Math . round ( r . width ) , Math . round ( r . height ) ] ;
} ;
return {
topbar : box ( ".topbar" ) ,
computer : box ( "#strip-computer" ) ,
board : box ( "#board" ) ,
player : box ( "#strip-player" ) ,
info : box ( ".infoline" ) ,
prompt : box ( "#prompt" ) ,
actions : box ( "#actions" ) ,
} ;
} ) ;
}
let stableBoxes = null ;
let stableChecks = 0 ;
/** Portrait : rien ne bouge sous le doigt, d'une étape à l'autre de la
* partie (et dès le chargement). */
async function checkStable ( when ) {
const boxes = await zoneBoxes ( ) ;
if ( stableBoxes === null ) {
stableBoxes = boxes ;
const order = [ "topbar" , "computer" , "board" , "player" , "info" , "prompt" , "actions" ] ;
for ( let i = 1 ; i < order . length ; i ++ ) {
const above = boxes [ order [ i - 1 ] ] ;
check ( above [ 1 ] + above [ 3 ] <= boxes [ order [ i ] ] [ 1 ] + 0.5 ,
` portrait : ${ order [ i - 1 ] } n'est pas au-dessus de ${ order [ i ] } ` ) ;
}
const actions = boxes . actions ;
check ( Math . abs ( actions [ 1 ] + actions [ 3 ] - PORTRAIT . height ) <= 1 ,
` portrait : barre d'actions pas collée en bas ( ${ JSON . stringify ( actions ) } ) ` ) ;
}
for ( const [ zone , box ] of Object . entries ( boxes ) ) {
check ( JSON . stringify ( box ) === JSON . stringify ( stableBoxes [ zone ] ) ,
` portrait : ${ zone } a bougé ( ${ when } ) : ${ JSON . stringify ( stableBoxes [ zone ] ) } -> ` +
JSON . stringify ( box ) ) ;
}
stableChecks ++ ;
}
/** Les critères de la recette vérifiables sur la vue courante. Renvoie des
* mesures pour le compte rendu. */
async function checkView ( width , height , when ) {
const where = ` ${ width } x ${ height } ( ${ when } ) ` ;
const seen = await page . evaluate ( ( ) => {
const visible = ( el ) => {
if ( el . closest ( "[hidden]" ) ) {
return false ;
}
const r = el . getBoundingClientRect ( ) ;
if ( r . width === 0 || r . height === 0 ) {
return false ;
}
const style = getComputedStyle ( el ) ;
return style . visibility !== "hidden" && style . display !== "none" ;
} ;
const describe = ( el ) =>
el . id ? ` # ${ el . id } ` : el . getAttribute ( "data-action" ) ? ? el . getAttribute ( "aria-label" ) ? ?
el . className ? . baseVal ? ? el . className ;
// Cibles tactiles : boutons et zones d'action du plateau.
const small = [ ] ;
for ( const el of document . querySelectorAll ( "button, [role=button], [role=switch], [data-action]" ) ) {
if ( ! visible ( el ) ) {
continue ;
}
const r = el . getBoundingClientRect ( ) ;
if ( r . width < 43.5 || r . height < 43.5 ) {
small . push ( ` ${ describe ( el ) } ${ Math . round ( r . width ) } x ${ Math . round ( r . height ) } ` ) ;
}
}
// Textes du plateau : taille à l'écran.
const scaleOf = ( el ) => {
const m = el . getScreenCTM ( ) ;
return Math . hypot ( m . a , m . b ) ;
} ;
let minNumber = Infinity ;
const smallText = [ ] ;
for ( const text of document . querySelectorAll ( "#board text" ) ) {
const px = parseFloat ( getComputedStyle ( text ) . fontSize ) * scaleOf ( text ) ;
if ( text . classList . contains ( "point-number" ) ) {
minNumber = Math . min ( minNumber , px ) ;
} else if ( px < 12.9 ) {
smallText . push ( ` ${ text . getAttribute ( "class" ) } ${ px . toFixed ( 1 ) } px ` ) ;
}
}
// Textes de la page à lire : 13 px au moins.
for ( const el of document . querySelectorAll ( "body *:not(svg *)" ) ) {
const own = [ ... el . childNodes ] . some ( ( n ) => n . nodeType === 3 && n . textContent . trim ( ) ) ;
if ( own && visible ( el ) && parseFloat ( getComputedStyle ( el ) . fontSize ) < 13 ) {
smallText . push ( ` ${ describe ( el ) } ${ getComputedStyle ( el ) . fontSize } ` ) ;
}
}
// Couleurs des flèches : impaires d'une couleur, paires d'une autre.
const fills = { } ;
for ( const p of document . querySelectorAll ( "#board polygon[data-point]" ) ) {
fills [ p . getAttribute ( "data-point" ) ] = getComputedStyle ( p ) . fill ;
}
// Boutons principaux (brique) visibles.
const primaries = [ ... document . querySelectorAll (
"button:not(.secondary):not(.icon-button):not(.item):not(.count):not(.link-button)" ,
) ] . filter ( visible ) . map ( describe ) ;
const board = document . getElementById ( "board" ) . getBoundingClientRect ( ) ;
const frame = document . querySelector ( "#board .board-frame" ) . getBoundingClientRect ( ) ;
const scroller = document . scrollingElement ;
return {
small ,
minNumber ,
smallText ,
fills ,
primaries ,
landscape : document . documentElement . classList . contains ( "landscape" ) ,
board : [ board . x , board . y , board . width , board . height ] ,
frame : [ frame . x , frame . y , frame . width , frame . height ] ,
scroll : [ scroller . scrollWidth , scroller . scrollHeight ] ,
topbar : visible ( document . querySelector ( ".topbar" ) ) ,
overscroll : getComputedStyle ( document . documentElement ) . overscrollBehaviorY ,
pointHits : [ ... document . querySelectorAll ( '#board [data-place^="p"]' ) ] . map (
( el ) => el . getBoundingClientRect ( ) . width ,
) ,
} ;
} ) ;
check ( seen . small . length === 0 , ` ${ where } : cibles sous 44 px : ${ seen . small . join ( ", " ) } ` ) ;
check ( seen . minNumber >= 12 , ` ${ where } : numéros des flèches à ${ seen . minNumber . toFixed ( 1 ) } px ` ) ;
check ( seen . smallText . length === 0 , ` ${ where } : textes sous 13 px : ${ seen . smallText . join ( ", " ) } ` ) ;
const odd = new Set ( ) ;
const even = new Set ( ) ;
for ( const [ n , fill ] of Object . entries ( seen . fills ) ) {
( Number ( n ) % 2 ? odd : even ) . add ( fill ) ;
}
check ( Object . keys ( seen . fills ) . length === 24 && odd . size === 1 && even . size === 1 &&
[ ... odd ] [ 0 ] !== [ ... even ] [ 0 ] && seen . fills [ 12 ] !== seen . fills [ 13 ] ,
` ${ where } : couleurs des flèches ${ JSON . stringify ( seen . fills ) } ` ) ;
check ( seen . primaries . length <= 1 , ` ${ where } : plusieurs actions principales : ${ seen . primaries } ` ) ;
check ( seen . overscroll === "none" , ` ${ where } : overscroll-behavior ${ seen . overscroll } ` ) ;
const landscape = width > height && height <= 640 ;
check ( seen . landscape === landscape , ` ${ where } : mise en page ${ seen . landscape ? "paysage" : "portrait" } ` ) ;
if ( landscape ) {
const edge = ( box ) => box . every ( ( v , i ) => Math . abs ( v - [ 0 , 0 , width , height ] [ i ] ) <= 1 ) ;
check ( edge ( seen . board ) && edge ( seen . frame ) ,
` ${ where } : cadre pas bord à bord : ${ seen . board } / ${ seen . frame } ` ) ;
check ( seen . scroll [ 0 ] <= width && seen . scroll [ 1 ] <= height , ` ${ where } : la page défile ` ) ;
check ( ! seen . topbar , ` ${ where } : barre haute visible en paysage ` ) ;
check ( Math . min ( ... seen . pointHits ) >= 44 ,
` ${ where } : flèches de ${ Math . min ( ... seen . pointHits ) . toFixed ( 1 ) } px ` ) ;
}
return seen ;
}
/** Ce qui doit survivre à une rotation : partie, sélection, dés. */
async function screenState ( ) {
return page . evaluate ( ( ) => ( {
head : JSON . parse ( localStorage . getItem ( "ustagammon.current-game" ) ) ? . head ? ? null ,
selected : document . querySelector ( "#board .checker.selected" ) !== null ,
landing : [ ... document . querySelectorAll ( "#board .hit.can-land" ) ]
. map ( ( e ) => e . getAttribute ( "data-place" ) )
. join ( " " ) ,
dice : [ ... document . querySelectorAll ( "#board g.die[data-value]" ) ]
. map ( ( d ) => ` ${ d . getAttribute ( "data-value" ) } ${ d . classList . contains ( "die-used" ) ? "u" : "" } ` )
. join ( " " ) ,
traces : document . querySelectorAll ( ".trace-arrow" ) . length ,
menu : ! document . getElementById ( "menu-veil" ) . hidden ,
panel : ! document . getElementById ( "panel-veil" ) . hidden ,
} ) ) ;
}
const recette = { views : 0 , captures : 0 , minNumber : Infinity , rotations : 0 } ;
/**
* Un moment de la partie : dans chaque vue (trois tailles, deux thèmes),
* vérifie la recette et l'état conservé par la rotation, et range la
* capture. Revient ensuite en portrait clair.
*/
async function review ( moment ) {
const before = await screenState ( ) ;
const views = fullReview ? VIEWS : [ [ PORTRAIT . width , PORTRAIT . height ] , [ 844 , 320 ] ] ;
const themes = fullReview ? [ "light" , "dark" ] : [ "light" ] ;
for ( const [ width , height ] of views ) {
await page . setViewportSize ( { width , height } ) ;
for ( const theme of themes ) {
await page . emulateMedia ( { colorScheme : theme } ) ;
await sleep ( 120 ) ;
const seen = await checkView ( width , height , moment ) ;
recette . views ++ ;
recette . minNumber = Math . min ( recette . minNumber , seen . minNumber ) ;
const now = await screenState ( ) ;
check ( JSON . stringify ( now ) === JSON . stringify ( before ) ,
` ${ moment } , ${ width } x ${ height } : état perdu à la rotation : ` +
` ${ JSON . stringify ( before ) } -> ${ JSON . stringify ( now ) } ` ) ;
recette . rotations ++ ;
if ( fullReview ) {
await page . screenshot ( { path : ` ${ reviewDir } / ${ moment } - ${ width } x ${ height } - ${ theme } .png ` } ) ;
recette . captures ++ ;
}
}
}
await page . setViewportSize ( PORTRAIT ) ;
await page . emulateMedia ( { colorScheme : "light" } ) ;
await sleep ( 120 ) ;
check ( JSON . stringify ( await screenState ( ) ) === JSON . stringify ( before ) ,
` ${ moment } : état perdu au retour en portrait ` ) ;
}
// --------------------------------------------------- Coup de l'ordinateur
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
function computerMoveToShow ( journal ) {
@@ -197,8 +462,8 @@ async function touchTarget(action, label, count = 1, index = 0) {
/** Le nombre de dés montrés, et de dés estompés (déjà joués). */
async function shownDice ( ) {
return {
dice : await page . locator ( "g.die[data-value]" ) . count ( ) ,
used : await page . locator ( "g.die-used" ) . count ( ) ,
dice : await page . locator ( "#board g.die[data-value]" ) . count ( ) ,
used : await page . locator ( "#board g.die-used" ) . count ( ) ,
} ;
}
@@ -215,12 +480,13 @@ async function watchReplay(ms) {
let arrows = Infinity ;
for ( const end = Date . now ( ) + ms ; Date . now ( ) < end ; ) {
const seen = await page . evaluate ( ( ) => ( {
animations : document . getAnimations ( ) . length ,
animations : document . getAnimations ( ) . filter ( ( a ) => a . effect ? . target ? . closest ? . ( "#board" ) )
. length ,
arrows : document . querySelectorAll ( ".trace-arrow" ) . length ,
} ) ) ;
animations = Math . max ( animations , seen . animations ) ;
arrows = Math . min ( arrows , seen . arrows ) ;
await new Promise ( ( resolve ) => setTimeout ( resolve , 40 ) ) ;
await sleep ( 30 ) ;
}
return { animations , arrows } ;
}
@@ -230,7 +496,7 @@ async function watchReplay(ms) {
async function checkReplay ( traces , reducedMotion ) {
await page . emulateMedia ( { reducedMotion : reducedMotion ? "reduce" : "no-preference" } ) ;
await page . click ( "#replay" ) ;
const seen = await watchReplay ( 300 ) ;
const seen = await watchReplay ( 250 ) ;
if ( reducedMotion ) {
check ( seen . animations === 0 , "Revoir en mouvement réduit : des pions glissent quand même" ) ;
} else {
@@ -244,6 +510,32 @@ async function checkReplay(traces, reducedMotion) {
await page . emulateMedia ( { reducedMotion : "no-preference" } ) ;
}
// ------------------------------------------------------------- Menu
async function openMenu ( ) {
await page . click ( "#menu-button" ) ;
check ( await page . isVisible ( "#menu" ) , "le menu ne s'ouvre pas" ) ;
}
/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie
* le journal de la partie quittée. */
async function newGameFromMenu ( ) {
const before = await savedJournal ( ) ;
await openMenu ( ) ;
const start = Date . now ( ) ;
await page . click ( "#menu-new-game" ) ;
check ( await page . isVisible ( "#banner" ) , "pas de bandeau « Reprendre l'ancienne »" ) ;
check ( ( await page . textContent ( "#banner" ) ) . includes ( "Reprendre l'ancienne" ) ,
` bandeau : « ${ await page . textContent ( "#banner" ) } » ` ) ;
check ( ! ( await page . isVisible ( "#menu" ) ) , "le menu reste ouvert après « Nouvelle partie »" ) ;
await waitForPlayer ( ) ;
const after = await savedJournal ( ) ;
check ( after . head !== before . head && ! after . events . some ( ( e ) => e . type === "MovePlayed" &&
e . side === "player" ) , "« Nouvelle partie » n'a pas commencé de partie" ) ;
log ( ` nouvelle partie commencée en ${ Date . now ( ) - start } ms, sans confirmation ` ) ;
return before ;
}
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
// capture et l'état de l'écran.
let lastStep = Date . now ( ) ;
@@ -253,48 +545,132 @@ function step(name) {
stepName = name ;
}
const watchdog = setInterval ( async ( ) => {
if ( Date . now ( ) - lastStep > 120_000 ) {
if ( Date . now ( ) - lastStep > 180_000 ) {
clearInterval ( watchdog ) ;
console . error ( ` [ ${ browserName } ] bloqué depuis 2 minutes (étape : ${ stepName } ) ` ) ;
console . error ( ` [ ${ browserName } ] bloqué depuis 3 minutes (étape : ${ stepName } ) ` ) ;
await shot ( "bloque" ) . catch ( ( ) => { } ) ;
process . exit ( 1 ) ;
}
} , 5_000 ) ;
// ------------------------------------------------------------- Chargement
// Le moteur est retenu le temps de regarder l'écran de chargement : le
// plateau doit être là sans lui (réponse immédiate).
let releaseEngine ;
const engineHeld = new Promise ( ( resolve ) => {
releaseEngine = resolve ;
} ) ;
await page . route ( "**/wasm/engine.data" , async ( route ) => {
await engineHeld ;
await route . continue ( ) ;
} ) ;
const start = Date . now ( ) ;
await page . goto ( base ) ;
const first = await waitForPlayer ( ) ;
log ( ` moteur chargé, partie prête en ${ Date . now ( ) - start } ms ( ${ first } ) ` ) ;
await page . waitForSelector ( "#board polygon[data-point]" ) ;
{
const loading = await page . evaluate ( ( ) => ( {
busy : document . body . classList . contains ( "busy" ) ,
points : document . querySelectorAll ( "#board polygon[data-point]" ) . length ,
checkers : document . querySelectorAll ( "#board .checker" ) . length ,
info : document . getElementById ( "info" ) . textContent ,
moves : document . querySelectorAll ( ".hit.can-move, [data-action=roll]" ) . length ,
} ) ) ;
check ( loading . busy && loading . points === 24 && loading . checkers === 30 ,
` chargement : plateau incomplet ${ JSON . stringify ( loading ) } ` ) ;
check ( loading . info === "Chargement du moteur…" , ` chargement : « ${ loading . info } » ` ) ;
check ( loading . moves === 0 , "chargement : le plateau est jouable avant le moteur" ) ;
log ( ` plateau dessiné en ${ Date . now ( ) - start } ms, avant le moteur ( ${ loading . checkers } pions) ` ) ;
}
await checkStable ( "chargement" ) ;
await review ( "1-chargement" ) ;
releaseEngine ( ) ;
await page . unroute ( "**/wasm/engine.data" ) ;
let todo = await waitForPlayer ( ) ;
log ( ` moteur chargé, partie prête en ${ Date . now ( ) - start } ms ( ${ todo } ) ` ) ;
await checkStable ( "moteur chargé" ) ;
await shot ( "1-debut" ) ;
// Nouvelle partie dès le début : le bandeau disparaît après 10 s, et
// l'ancienne partie n'est alors plus proposée.
step ( "bandeau de 10 s" ) ;
await newGameFromMenu ( ) ;
await sleep ( 10_300 ) ;
check ( ! ( await page . isVisible ( "#banner" ) ) , "le bandeau « Reprendre l'ancienne » reste après 10 s" ) ;
log ( "bandeau « Reprendre l'ancienne » retiré après 10 s" ) ;
let doubled = false ;
let selectionShot = false ;
let reloaded = false ;
let computerMs = [ ] ;
let resumed = false ;
let landscapeTurn = false ;
let rotatedChecked = false ;
let vibrationsChecked = false ;
let tracesSeen = 0 ;
let replays = 0 ;
let skips = 0 ;
let skipsByDice = 0 ;
let rolls = 0 ;
// Saisie du coup : demi-coups du coup en cours, pions avancés d'un seul
// toucher ou en deux (pion, puis case), coups validés et annulations.
let keyboardRolls = 0 ;
let halfMoves = 0 ;
let singleTouch = 0 ;
let twoTouches = 0 ;
let confirms = 0 ;
let undone = 0 ;
let doubles = 0 ;
let responseMs = 0 ;
const moments = new Set ( ) ;
// Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup :
// le coup de l'ordinateur n'est plus montré.
let touched = false ;
// Vrai juste après le rechargement : la partie reprise ne revibre pas.
let justReloaded = false ;
/** Un tour complet joué en paysage (844 × 320), par les zones du plateau :
* dés, pions, dés pour jouer le coup. */
async function landscapeTurnPlay ( ) {
await page . setViewportSize ( { width : 844 , height : 320 } ) ;
await sleep ( 150 ) ;
await checkView ( 844 , 320 , "tour en paysage" ) ;
await ( await touchTarget ( "roll" , "Lancer les dés" , 2 , 1 ) ) . click ( ) ;
for ( let n = 0 ; n < 6 ; n ++ ) {
const now = await waitForPlayer ( ) ;
if ( now === "confirm" ) {
await ( await touchTarget ( "confirm" , "Jouer ce coup" , 2 , 0 ) ) . click ( ) ;
break ;
}
if ( now === "skip" ) {
await ( await touchTarget ( "skip" , "Passer votre tour" , 2 , 0 ) ) . click ( ) ;
break ;
}
check ( now === "move" , ` paysage : ${ now } pendant le coup ` ) ;
await page . click ( ".hit.can-move" ) ;
if ( ( await page . locator ( ".hit.can-land" ) . count ( ) ) > 0 ) {
await page . click ( ".hit.can-land" ) ;
}
}
await waitForPlayer ( ) ;
await page . setViewportSize ( PORTRAIT ) ;
await sleep ( 150 ) ;
log ( "un tour joué en paysage (844 x 320) par les zones du plateau" ) ;
}
for ( let actions = 0 ; ; actions ++ ) {
check ( actions < 1500 , "la partie ne finit pas" ) ;
const todo = await waitForPlayer ( ) ;
todo = await waitForPlayer ( ) ;
step ( ` partie, action ${ actions } ` ) ;
const afterReload = justReloaded ;
justReloaded = false ;
await checkStable ( ` action ${ actions } ( ${ todo } ) ` ) ;
// Le coup de l'ordinateur est montré jusqu'à l'action suivante du
// joueur ; deux fois, on le revoit (la seconde en mouvement réduit).
const traces = touched ? null : await checkTraces ( ) ;
if ( traces !== null ) {
tracesSeen ++ ;
if ( ! moments . has ( "coup-ordinateur" ) ) {
moments . add ( "coup-ordinateur" ) ;
await review ( "4-coup-ordinateur" ) ;
}
if ( replays < 2 ) {
replays ++ ;
await checkReplay ( traces , replays === 2 ) ;
@@ -306,71 +682,131 @@ for (let actions = 0; ; actions++) {
` ${ traces . ghosts } fantômes, ${ traces . hits } pions frappés signalés ` ) ;
}
}
const info = ( await page . textContent ( "#info" ) ) ? ? "" ;
check ( ! /\(\d+,\d s\)/ . test ( info ) , ` durée de calcul affichée : « ${ info } » ` ) ;
if ( process . env . TRACE ) {
log ( ` ${ todo } : ${ await page . textContent ( "#info" ) } / ${ await page . textContent ( "#prompt" ) } ` ) ;
log ( ` ${ todo } : ${ info } / ${ await page . textContent ( "#prompt" ) } ` ) ;
}
const info = await page . textContent ( "#info ") ;
const timing = /\((\d+),(\d) s\)/ . exec ( info ? ? "" ) ;
if ( timing ) {
computerMs . push ( Number ( timing [ 1 ] ) * 1000 + Number ( timing [ 2 ] ) * 100 ) ;
}
if ( todo === "again" ) {
if ( todo === "over ") {
break ;
}
if ( todo === "skip" ) {
// Aucun coup possible : le jet reste affiché, barré, avec un
// message, et le tour ne passe qu'en touchant « Continuer » .
const info = await page . textContent ( "#info" ) ;
// message ; le tour ne passe qu'en touchant les dés (ou Continuer) .
const blocked = await page . locator ( ".die-blocked" ) . count ( ) ;
check ( /^Aucun coup possible avec \d-\d\.$/ . test ( info ? ? "" ) && blocked >= 2 &&
check ( /^Aucun coup possible avec \d-\d\.$/ . test ( info ) && blocked >= 2 &&
blocked === ( await shownDice ( ) ) . dice ,
` aucun coup possible mal montré : « ${ info } », ${ blocked } dés barrés ` ) ;
await sleep ( 400 ) ;
check ( await page . isVisible ( "#skip" ) , "aucun coup possible : le tour est passé tout seul" ) ;
skips ++ ;
log ( ` aucun coup possible : « ${ info } », vous continuez ` ) ;
await page . click ( "#skip" ) ;
if ( skips % 2 === 1 ) {
skipsByDice ++ ;
await ( await touchTarget ( "skip" , "Passer votre tour" , 2 , skips % 2 ) ) . click ( ) ;
} else {
await page . click ( "#skip" ) ;
}
log ( ` aucun coup possible : « ${ info } », vous passez votre tour ` ) ;
} else if ( todo === "take" ) {
log ( "l'ordinateur double : vous prenez" ) ;
await shot ( "videau-propose" ) ;
check ( afterReload || ( await vibrations ( ) ) . includes ( "40,60,40" ) ,
"l'ordinateur double sans vibration" ) ;
await page . click ( "#take" ) ;
} else if ( todo === "roll" ) {
if ( ! doubled && ( await page . isEnabled ( "#double" ) ) ) {
if ( ! moments . has ( "a-vous" ) ) {
moments . add ( "a-vous" ) ;
await review ( "2-a-vous-de-lancer" ) ;
}
if ( ! doubled && ( await page . isVisible ( "#double" ) ) && ( await page . isEnabled ( "#double" ) ) ) {
doubled = true ;
// Toucher le videau demande confirmation ; « Annuler » ne
// propose rien, puis « Doubler » (le bouton) et confirmer.
const dialog = page . locator ( "#double-dialog" ) ;
const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer." ;
await ( await touchTarget ( "double" , "Doubler" ) ) . click ( ) ;
check ( await dialog . isVisible ( ) , "videau touché : pas de confirmation" ) ;
check ( await page . isVisible ( "#double-dialog" ) , "videau touché : pas de confirmation" ) ;
check ( ( await page . textContent ( "#double-question" ) ) === question ,
` confirmation du videau : « ${ await page . textContent ( "#double-question" ) } » ` ) ;
await shot ( "videau-confirmation" ) ;
await page . click ( "#double-cancel" ) ;
check ( ! ( await dialog . isVisible ( ) ) , "Annuler ne ferme pas la confirmation" ) ;
check ( ! ( await page . isVisible ( "#double-dialog" ) ) , "Annuler ne ferme pas la confirmation" ) ;
const offered = ( await savedJournal ( ) ) . events . some ( ( e ) => e . type === "CubeOffered" ) ;
check ( ! offered && ( await page . isVisible ( "#roll" ) ) , "double proposé malgré Annuler" ) ;
await page . click ( "#double" ) ;
check ( await dialog . isVisible ( ) , "« Doubler » : pas de confirmation" ) ;
check ( await page . isVisible ( "#double-dialog" ) , "« Doubler » : pas de confirmation" ) ;
await page . click ( "#double-confirm" ) ;
await waitForPlayer ( ) ;
const answer = ( await page . textContent ( "#info" ) ) || ( await page . textContent ( "#prompt" ) ) ;
log ( ` videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${ answer } ` ) ;
continue ;
}
// Une fois sur deux, en touchant les dés plutôt que le bouton, d'un
// côté puis de l'autre du plateau.
rolls ++ ;
if ( rolls % 2 === 0 ) {
if ( rolls === 3 && ! landscapeTurn ) {
landscapeTurn = true ;
await landscapeTurnPlay ( ) ;
continue ;
}
if ( rolls === 4 && ! rotatedChecked ) {
// Plein écran : Chromium sans écran refuse de verrouiller
// l'orientation, d'où le repli par rotation CSS de tout l'écran.
rotatedChecked = true ;
await page . click ( "#fullscreen-button" ) ;
await sleep ( 300 ) ;
const view = await page . evaluate ( ( ) => {
const r = document . getElementById ( "board" ) . getBoundingClientRect ( ) ;
return {
rotated : document . documentElement . classList . contains ( "rotated" ) ,
landscape : document . documentElement . classList . contains ( "landscape" ) ,
board : [ r . x , r . y , r . width , r . height ] . map ( Math . round ) ,
} ;
} ) ;
check ( view . landscape , "plein écran : pas de vue paysage" ) ;
if ( view . rotated ) {
check ( JSON . stringify ( view . board ) === JSON . stringify ( [ 0 , 0 , 412 , 915 ] ) ,
` repli tourné : plateau ${ view . board } ` ) ;
}
await shot ( "plein-ecran" ) ;
if ( fullReview ) {
await page . screenshot ( { path : ` ${ reviewDir } /plein-ecran-repli-412x915-light.png ` } ) ;
}
// Les touchers suivent la rotation : lancer les dés par le plateau.
await ( await touchTarget ( "roll" , "Lancer les dés" , 2 , 1 ) ) . click ( ) ;
await waitForPlayer ( ) ;
check ( ( await savedJournal ( ) ) . events . at ( - 1 ) . type === "DiceRolled" ,
"repli tourné : toucher les dés ne les lance pas" ) ;
// Sortie par le menu (colonne de commandes), puis « Quitter ».
await ( await touchTarget ( "menu" , "Menu" ) ) . click ( ) ;
check ( ( await page . textContent ( "#menu-fullscreen" ) ) . includes ( "Quitter le plein écran" ) ,
"menu : pas de « Quitter le plein écran »" ) ;
await page . click ( "#menu-fullscreen" ) ;
await sleep ( 300 ) ;
check ( ! ( await page . evaluate ( ( ) => document . documentElement . classList . contains ( "landscape" ) ) ) ,
"plein écran quitté : la vue paysage reste" ) ;
log ( ` plein écran : ${ view . rotated ? "repli par rotation CSS" : "paysage verrouillé" } , ` +
"dés lancés en touchant le plateau tourné, sortie par le menu" ) ;
continue ;
}
// Dés : au clavier (Espace), en les touchant (d'un côté puis de
// l'autre du plateau) ou par le bouton.
const before = ( await vibrations ( ) ) . length ;
if ( rolls % 5 === 0 ) {
keyboardRolls ++ ;
await page . evaluate ( ( ) => document . activeElement ? . blur ( ) ) ;
await page . keyboard . press ( "Space" ) ;
} else if ( rolls % 2 === 0 ) {
await ( await touchTarget ( "roll" , "Lancer les dés" , 2 , ( rolls / 2 ) % 2 ) ) . click ( ) ;
} else {
await page . click ( "#roll" ) ;
}
await waitForPlayer ( ) ;
check ( ( await vibrations ( ) ) . length === before , "le lancer des dés fait vibrer" ) ;
} else if ( todo === "confirm" ) {
// Coup complet : rien n'est joué tant qu'il n'est pas validé.
const journal = await savedJournal ( ) ;
const last = journal . events . at ( - 1 ) ;
check ( last ? . type === "DiceRolled" && ( await page . isEnabled ( "#cancel" ) ) ,
` coup complet déjà joué ou non annulable (dernier événement ${ last ? . type } ) ` ) ;
check ( /Valider/ . test ( ( await page . textContent ( "#prompt" ) ) ? ? "" ) ,
check ( /jouer ce coup/i . test ( ( await page . textContent ( "#prompt" ) ) ? ? "" ) ,
` consigne de validation absente : « ${ await page . textContent ( "#prompt" ) } » ` ) ;
const dice = await shownDice ( ) ;
check ( dice . used === halfMoves && dice . used > 0 ,
@@ -400,17 +836,17 @@ for (let actions = 0; ; actions++) {
if ( confirms % 3 === 0 ) {
await page . click ( "#confirm" ) ;
} else {
await ( await touchTarget ( "confirm" , "Valider " , 2 , confirms % 2 ) ) . click ( ) ;
await ( await touchTarget ( "confirm" , "Jouer ce coup " , 2 , confirms % 2 ) ) . click ( ) ;
}
await waitForPlayer ( ) ;
const played = await savedJournal ( ) ;
check ( countMoves ( played , "player" ) === countMoves ( journal , "player" ) + 1 ,
"le coup valid é n'est pas dans le journal" ) ;
"le coup jou é n'est pas dans le journal" ) ;
halfMoves = 0 ;
touched = false ;
} else {
check ( ( await page . locator ( "[data-action]" ) . count ( ) ) === 0 ,
"dés ou videau touchables pendant que vous jouez votre coup" ) ;
check ( ( await page . locator ( "[data-action=roll], [data-action=confirm], [data-action=double ]" )
. count ( ) ) === 0 , "dés ou videau touchables pendant que vous jouez votre coup" ) ;
if ( halfMoves === 0 ) {
// Quatre dés pour un double, deux sinon.
const [ a , b ] = lastPlayerRoll ( await savedJournal ( ) ) ;
@@ -422,14 +858,36 @@ for (let actions = 0; ; actions++) {
}
}
touched = true ;
// Vibrations coupées dans le menu : le pion se pose sans vibrer.
const silent = reloaded && ! vibrationsChecked ;
if ( silent ) {
await openMenu ( ) ;
await page . click ( "#menu-vibrations" ) ;
check ( ( await page . getAttribute ( "#menu-vibrations" , "aria-checked" ) ) === "false" ,
"interrupteur des vibrations" ) ;
await page . keyboard . press ( "Escape" ) ;
check ( ! ( await page . isVisible ( "#menu" ) ) , "Échap ne ferme pas le menu" ) ;
}
const vibrationsBefore = ( await vibrations ( ) ) . length ;
// Un pion à destination unique avance dès qu'on le touche ; sinon,
// ses cases d'arrivée s'allument.
await page . click ( ".hit.can-move" ) ;
// ses cases d'arrivée s'allument. Le premier toucher est mesuré :
// l'écran répond dans le gestionnaire du toucher.
const ms = await page . evaluate ( ( ) => {
const hit = document . querySelector ( ".hit.can-move" ) ;
const t0 = performance . now ( ) ;
hit . dispatchEvent ( new MouseEvent ( "click" , { bubbles : true } ) ) ;
const answered = document . querySelector ( "#board .checker.selected" ) !== null ||
document . body . classList . contains ( "busy" ) ;
return answered ? performance . now ( ) - t0 : Infinity ;
} ) ;
responseMs = Math . max ( responseMs , ms ) ;
check ( ms < 100 , ` toucher d'un pion : pas d'effet visible en moins de 100 ms ( ${ ms } ) ` ) ;
if ( ( await page . locator ( ".hit.can-land" ) . count ( ) ) > 0 ) {
twoTouches ++ ;
if ( ! selectionShot ) {
selectionShot = true ;
await shot ( "2-pion-touche" ) ;
await review ( "3-coup-en-cours" ) ;
}
await page . click ( ".hit.can-land" ) ;
} else {
@@ -439,87 +897,140 @@ for (let actions = 0; ; actions++) {
await waitForPlayer ( ) ;
const dice = await shownDice ( ) ;
check ( dice . used === halfMoves , ` ${ dice . used } dés estompés après ${ halfMoves } demi-coups ` ) ;
const vibrationsAfter = ( await vibrations ( ) ) . length ;
if ( silent ) {
vibrationsChecked = true ;
check ( vibrationsAfter === vibrationsBefore , "vibrations coupées : le pion vibre encore" ) ;
await openMenu ( ) ;
await page . click ( "#menu-vibrations" ) ;
await page . click ( "#menu-close" ) ;
log ( "vibrations coupées dans le menu : le pion se pose sans vibrer" ) ;
} else {
const added = ( await vibrations ( ) ) . slice ( vibrationsBefore ) ;
check ( added . length === 1 && [ "12" , "25,40,25" ] . includes ( added [ 0 ] ) ,
` pion posé : vibrations ${ JSON . stringify ( added ) } ` ) ;
}
continue ;
}
// Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir
// la page.
// la page ; puis une nouvelle partie, et la reprise de l'ancienne .
const journal = await savedJournal ( ) ;
if ( ! reloaded && countMoves ( journal , "computer" ) >= 3 && countMoves ( journal , "player" ) >= 3 ) {
reloaded = true ;
await waitForPlayer ( ) ;
await shot ( "3-milieu-de-partie" ) ;
const before = await savedJournal ( ) ;
const pips = await page . textContent ( "#pips " ) ;
const pips = await page . textContent ( "#strip-player .count " ) ;
await collectViolations ( ) ;
await page . reload ( ) ;
// Avant le moteur, le plateau de la partie enregistrée est déjà là.
await page . waitForSelector ( "#board polygon[data-point]" ) ;
check ( ( await page . textContent ( "#strip-player .count" ) ) === pips ,
"chargement : le plateau dessiné n'est pas celui de la partie enregistrée" ) ;
await waitForPlayer ( ) ;
const after = await savedJournal ( ) ;
check ( after . head === before . head , "la partie n'a pas été reprise après rechargement" ) ;
check ( ( await page . textContent ( "#pips" ) ) === pips , "plateau différent après rechargement" ) ;
check ( ( await page . textContent ( "#strip-player .count" ) ) === pips ,
"plateau différent après rechargement" ) ;
justReloaded = true ;
log ( ` partie reprise après rechargement (événement ${ after . head } ) ` ) ;
} else if ( reloaded && ! resumed ) {
resumed = true ;
step ( "menu" ) ;
await openMenu ( ) ;
await review ( "5-menu-ouvert" ) ;
await page . keyboard . press ( "Escape" ) ;
// Le clavier ouvre aussi le menu (M) et Échap le ferme.
await page . evaluate ( ( ) => document . activeElement ? . blur ( ) ) ;
await page . keyboard . press ( "m" ) ;
check ( await page . isVisible ( "#menu" ) , "M n'ouvre pas le menu" ) ;
await page . keyboard . press ( "Escape" ) ;
const pips = await page . textContent ( "#strip-player .count" ) ;
const old = await newGameFromMenu ( ) ;
await page . click ( "#resume-old" ) ;
await waitForPlayer ( ) ;
const back = await savedJournal ( ) ;
check ( JSON . stringify ( back ) === JSON . stringify ( old ) ,
"« Reprendre l'ancienne » ne restaure pas la partie exacte" ) ;
check ( ( await page . textContent ( "#strip-player .count" ) ) === pips ,
"« Reprendre l'ancienne » : plateau différent" ) ;
check ( ! ( await page . isVisible ( "#banner" ) ) , "le bandeau reste après la reprise" ) ;
log ( ` nouvelle partie puis « Reprendre l'ancienne » : journal identique ( ${ old . events . length } événements) ` ) ;
}
}
// --------------------------------------------------------- Fin de partie
const journal = await savedJournal ( ) ;
const result = await page . textContent ( "#promp t" ) ;
const result = await page . textContent ( "#panel-tex t" ) ;
log (
` partie finie : ${ result } ( ${ countMoves ( journal , "player" ) } coups du joueur, ` +
` ${ countMoves ( journal , "computer" ) } de l'ordinateur, ${ journal . events . length } événements) ` ,
) ;
const won = journal . events . at ( - 1 ) . winner === "player" ;
check ( ( await vibrations ( ) ) . at ( - 1 ) === ( won ? "30,50,30,50,60" : "60" ) ,
` fin de partie : vibration ${ ( await vibrations ( ) ) . at ( - 1 ) } ` ) ;
check ( ( await page . textContent ( "#panel-secondary" ) ) === "Revoir la partie" &&
( await page . textContent ( "#panel-primary" ) ) === "Nouvelle partie" ,
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »" ) ;
await review ( "6-fin-de-partie" ) ;
check ( countMoves ( journal , "player" ) >= 3 && countMoves ( journal , "computer" ) >= 3 , "trop peu de coups" ) ;
check ( reloaded , "la reprise après rechargement n'a pas été essayée ") ;
check ( reloaded && resumed , "rechargement ou reprise de l'ancienne partie non essayés ") ;
check ( replays === 2 , "le coup de l'ordinateur n'a pas été revu" ) ;
check ( doubled && rolls >= 2 , "dés ou videau jamais touchés sur le plateau" ) ;
check ( landscapeTurn && rotatedChecked , "paysage ou plein écran non essayés" ) ;
check ( vibrationsChecked , "interrupteur des vibrations non essayé" ) ;
check ( singleTouch > 0 && twoTouches > 0 ,
` saisie : ${ singleTouch } pions avancés d'un toucher, ${ twoTouches } en deux touchers ` ) ;
check ( undone >= 2 , "Annuler n'a pas été essayé sur un coup complet" ) ;
check ( confirms >= 3 , "trop peu de coups valid és pour essayer dés et bouton" ) ;
log ( ` dés lancés ${ Math . floor ( rolls / 2 ) } fois en les touchant, des deux côtés du plateau ` ) ;
log ( ` demi-coups : ${ singleTouch } d'un seul toucher (destination unique), ${ twoTouches } en deux ` +
` toucher s ; ${ confirms } coups validés ( ${ Math . floor ( confirms / 3 ) } par le bouton, les autres ` +
` en touchant les d és) ; ${ doubles } doubles montrés en quatre dés ` ) ;
check ( confirms >= 3 , "trop peu de coups jou és pour essayer dés et bouton" ) ;
check ( moments . size === 2 && selectionShot , ` moments de la recette manquants : ${ [ ... moments ] } ` ) ;
log ( ` dés lancés ${ rolls } fois ( ${ keyboardRolls } au clavier), dont la moitié en les touchant ` ) ;
log ( ` demi-coup s : ${ singleTouch } d'un seul toucher, ${ twoTouches } en deux touchers ; ` +
` ${ confirms } coups jou és ; ${ doubles } doubles montrés en quatre dés ` ) ;
log ( ` coup de l'ordinateur montré ${ tracesSeen } fois, et effacé à chaque action suivante ` ) ;
log ( skips > 0 ? ` « Continuer » demandé ${ skips } fois ` : "aucun jet sans coup possib le dans cette partie" ) ;
if ( computerMs . length > 0 ) {
log ( ` temps affichés des tours de l'ordinateur : max ${ Math . max ( ... computerMs ) } ms, ` +
` ${ computerMs . length } tours ` ) ;
}
log ( skips > 0 ? ` aucun coup possible ${ skips } fois ( ${ skipsByDice } en touchant les dés) `
: "aucun jet sans coup possible dans cette partie" ) ;
log ( ` vibrations demandées : ${ ( await vibrations ( ) ) . length } ` ) ;
await shot ( "4-fin-de-partie" ) ;
// « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la
// barre d'actions.
await page . click ( "#panel-secondary" ) ;
check ( ! ( await page . isVisible ( "#panel" ) ) && ( await page . isVisible ( "#again" ) ) ,
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »" ) ;
step ( "aide" ) ;
// Aide : scénario et glossaire lus dans les copies de docs/.
// Aide : scénarios et glossaire lus dans les copies de docs/.
await page . click ( "#help-button" ) ;
await page . waitForSelector ( ".glossary dt" ) ;
const terms = await page . locator ( ".glossary dt" ) . count ( ) ;
const scenarios = await page . locator ( "#help-content h3" ) . count ( ) ;
check ( terms >= 20 && scenarios >= 5 , ` aide incomplète : ${ terms } termes, ${ scenarios } scénarios ` ) ;
check ( terms >= 20 && scenarios >= 9 , ` aide incomplète : ${ terms } termes, ${ scenarios } scénarios ` ) ;
log ( ` aide : ${ scenarios } scénarios, ${ terms } termes du glossaire ` ) ;
await shot ( "5-aide" ) ;
await page . click ( "#help-back" ) ;
step ( "compte de points" ) ;
// Toucher le compte de points : la bulle donne la fiche du glossaire.
await page . click ( "#strip-player .count" ) ;
await page . waitForSelector ( "#pip-bubble:not([hidden]) .bubble-text" ) ;
const bubble = await page . textContent ( "#pip-bubble .bubble-text" ) ;
check ( /^nombre total de cases que vos pions doivent encore parcourir/ . test ( bubble ) ,
` bulle du compte de points : « ${ bubble } » ` ) ;
await shot ( "compte-de-points" ) ;
await page . keyboard . press ( "Escape" ) ;
check ( ! ( await page . isVisible ( "#pip-bubble" ) ) , "Échap ne ferme pas la bulle" ) ;
log ( "compte de points : bulle avec la fiche du glossaire" ) ;
step ( "thème sombre" ) ;
// Thème sombre.
await page . emulateMedia ( { colorScheme : "dark" } ) ;
await page . click ( "#again" ) ;
await waitForPlayer ( ) ;
await shot ( "6-sombre" ) ;
await page . emulateMedia ( { colorScheme : "light" } ) ;
step ( "paysage" ) ;
// Téléphone tourné : le plateau et les boutons tiennent dans l'écran.
for ( const [ width , height ] of [ [ 915 , 412 ] , [ 740 , 360 ] ] ) {
await page . setViewportSize ( { width , height } ) ;
const board = await page . locator ( "#board" ) . boundingBox ( ) ;
const actions = await page . locator ( ".actions" ) . boundingBox ( ) ;
check ( board . y + board . height <= height && board . x + board . width <= width ,
` paysage ${ width } x ${ height } : plateau hors de l'écran ` ) ;
check ( actions . y + actions . height <= height , ` paysage ${ width } x ${ height } : boutons hors de l'écran ` ) ;
}
log ( "paysage : plateau et boutons visibles en entier" ) ;
await shot ( "6-paysage" ) ;
await page . setViewportSize ( { width : 360 , height : 740 } ) ;
step ( "mise en cache" ) ;
// Hors ligne : une fois le cache du service worker rempli, la page se
// recharge sans réseau, et le moteur répond.
@@ -576,6 +1087,7 @@ step("CSP");
// elle est bien appliquée : un script en ligne ajouté à la page est bloqué.
await collectViolations ( ) ;
check ( violations . length === 0 , ` violations de la CSP : ${ violations . join ( ", " ) } ` ) ;
check ( dialogs . length === 0 , ` boîtes natives appelées : ${ dialogs . join ( ", " ) } ` ) ;
cspProbe = true ;
const probe = await page . evaluate ( async ( ) => {
const script = document . createElement ( "script" ) ;
@@ -589,7 +1101,12 @@ check(
probe . violations . some ( ( v ) => v . startsWith ( "script-src" ) ) ,
"CSP : le script en ligne bloqué n'a pas été signalé" ,
) ;
log ( "CSP : aucune violation pendant la partie, script en ligne bloqué" ) ;
log ( "CSP : aucune violation pendant la partie, script en ligne bloqué ; aucune boîte native " ) ;
log ( ` recette : ${ recette . views } vues vérifiées ( ${ stableChecks } contrôles de stabilité du ` +
` portrait, ${ recette . rotations } rotations sans perte d'état), numéros des flèches à ` +
` ${ recette . minNumber . toFixed ( 1 ) } px au moins, réponse au toucher en ${ responseMs . toFixed ( 1 ) } ms ` +
` au plus ${ fullReview ? ` , ${ recette . captures } captures dans revision-ux/ ` : "" } ` ) ;
clearInterval ( watchdog ) ;
await browser . close ( ) ;