Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Finora abbiamo studiato quasi tutti gli ingredienti che rendono possibile l’autoplay di PlumePilot:
Ora possiamo cambiare prospettiva.
Invece di chiederci:
Quale funzione viene chiamata dopo quale funzione?
possiamo chiederci:
In quale stato si trova l’autoplay, quale evento è appena accaduto e quali transizioni sono ammesse da qui?
È una differenza importante.
Un programma sufficientemente semplice può essere letto come una sequenza:
A → B → C → D
L’autoplay di PlumePilot non funziona così.
Durante lo stesso video possono cambiare:
enabled;Perciò il comportamento reale assomiglia molto di più a un grafo:
┌──────────────┐
│ PAUSED │
└──────┬───────┘
│ resume
▼
┌──────────────┐
│ WATCHING │
└──────┬───────┘
│ video ended
▼
┌──────────────────┐
│ VERIFY COMPLETION│
└────────┬─────────┘
│
┌───────────┼───────────────┐
│ │ │
▼ ▼ ▼
NEXT VIDEO TEST BOUNDARY END OF CHAPTER
│ │
│ ├─ threshold → STOP
│ ├─ limit → STOP
│ └─ next chapter
▼
STOP / COMPLETE
Questo è il terreno naturale delle state machine.
Una state machine descrive il comportamento di un sistema attraverso alcuni elementi fondamentali:
La forma minima è:
STATO A --evento--> STATO B
Per esempio:
WATCHING --VIDEO_ENDED--> VERIFYING_COMPLETION
Ma spesso una transizione è valida soltanto se una condizione è vera:
WATCHING --VIDEO_ENDED [enabled]--> VERIFYING_COMPLETION
La condizione tra parentesi è una guardia.
Una guardia non descrive cosa fare: descrive se una transizione è ammessa.
Possiamo quindi separare tre domande:
Dove siamo? → stato
Cosa è successo? → evento
Possiamo farlo? → guardia
Solo dopo arriva:
Cosa facciamo? → azione / effetto
Questa separazione è molto utile quando le condizioni crescono.
Nel codice attuale non troviamo qualcosa come:
const autoplayMachine = createMachine(...);
Non esiste una singola variabile:
let autoplayState = "watching";
Lo stato è invece distribuito tra molte variabili.
In content.js troviamo, fra le altre:
let playbackContext = null;
let busy = false;
let enabled = true;
let stopAtTests = false;
let autoCompleteTests = false;
let autoplaySkipCompletedVideos = false;
let autoplayStopAt70Enabled = false;
let autoplayStopAt70Bypassed = false;
let stoppedAtTestContext = null;
A queste si aggiungono:
smartResumeRunning;chapterLimitEnabled;chapterLimitSession;lastVideo;sessionStorage;collectingCourseMaterials;Quello che abbiamo è quindi un vettore di stato.
Possiamo immaginarlo così:
autoplay = {
enabled,
busy,
modeAtTests,
currentLesson,
currentVideo,
stoppedAtTestContext,
chapterLimit,
threshold70,
recovery,
competingOperation,
discoveryRunning,
...
}
La macchina a stati è quindi già presente semanticamente, anche se non è dichiarata formalmente.
Questo accade molto spesso nei software reali.
Supponiamo di avere questo codice:
if (!enabled) return;
if (busy) return;
if (collectingCourseMaterials) return;
if (courseBatchRunning()) return;
Possiamo leggerlo come quattro if indipendenti.
Oppure possiamo leggerlo come:
La transizione verso
ADVANCINGè permessa soltanto quando l’autoplay è attivo, non è già impegnato e nessuna operazione incompatibile possiede il corso.
Questa seconda lettura ci porta verso la modellazione a stati.
In altre parole:
if statements
possono essere la rappresentazione concreta di:
guards su una transizione
La differenza è soprattutto mentale e architetturale.
Prima di definire gli stati, cerchiamo gli eventi.
Nel codice reale abbiamo eventi molto chiari.
attach(v) registra:
v.addEventListener("play", () => {
const row = currentLesson();
if (row) rememberPlaybackLesson(row, v);
...
});
Concettualmente:
VIDEO_PLAYED
Sempre in attach(v):
v.addEventListener("ended", () => {
void captureEndedVideoProgress(v);
if (!enabled || smartResumeRunning) {
return;
}
if (lastVideo === v) return;
lastVideo = v;
clearTimeout(timer);
timer = setTimeout(() => {
if (enabled) {
advance(v);
}
}, DELAY_MS);
});
Questo evento è chiarissimo:
VIDEO_ENDED
Ma già qui vediamo diverse guardie:
[enabled]
[!smartResumeRunning]
[lastVideo !== v]
E una transizione ritardata:
VIDEO_ENDED
↓
WAITING_ADVANCE_DELAY
↓ after DELAY_MS
ADVANCING
Il bridge invia PEGASO_AUTONEXT_STATE.
content.js aggiorna:
enabled = event.data.enabled !== false;
autoCompleteTests = event.data.autoCompleteTests === true;
autoplaySkipCompletedVideos = event.data.autoplaySkipCompletedVideos === true;
chapterLimitEnabled = event.data.autoplayChapterLimitEnabled === true;
autoplayStopAt70Enabled = event.data.autoplayStopAt70Enabled === true;
Questo può essere visto come:
SETTINGS_CHANGED
Lo stesso evento può causare transizioni diverse in base allo stato corrente.
Per esempio:
PAUSED --SETTINGS_CHANGED(enabled=true)--> WATCHING
oppure:
STOPPED_AT_TEST --SETTINGS_CHANGED(stopAtTests=false)--> RESUMING
Questo secondo caso esiste davvero nel codice.
Questa è una delle idee più importanti delle state machine.
Se arriva:
VIDEO_ENDED
non significa automaticamente:
vai al video successivo
Il sistema deve prima chiedersi:
Autoplay è attivo?
Sto già avanzando?
Quel video è ancora quello corrente?
La piattaforma ha registrato il completamento?
Il DOM rappresenta ancora la stessa lezione?
C'è un test?
Devo fermarmi al test?
Devo completarlo automaticamente?
Ho raggiunto il 70%?
Ho raggiunto il limite sessione?
Esiste un'altra operazione incompatibile?
Quindi l’evento è solo un trigger.
La transizione è una decisione.
Una modellazione concettuale utile dell’autoplay potrebbe essere:
stateDiagram-v2
[*] --> Paused: enabled = false
[*] --> Watching: enabled = true
Paused --> Watching: ENABLE
Watching --> Paused: DISABLE
Watching --> AdvanceDelay: VIDEO_ENDED
AdvanceDelay --> VerifyingCompletion: timer elapsed
VerifyingCompletion --> Watching: completion not confirmed
VerifyingCompletion --> OpeningNextVideo: next video exists
VerifyingCompletion --> AtTestBoundary: terminal test exists
VerifyingCompletion --> EndOfChapter: no next video
OpeningNextVideo --> Watching: new video ready
AtTestBoundary --> StoppedAtTest: stopAtTests
AtTestBoundary --> CompletingTest: autoCompleteTests
AtTestBoundary --> EndOfChapter: ignore test
CompletingTest --> EndOfChapter: test completed
CompletingTest --> SafeStop: completion failed
EndOfChapter --> ThresholdStop: progress >= 70% and rule enabled
EndOfChapter --> SessionLimitStop: session limit reached
EndOfChapter --> ChangingChapter: continue
ChangingChapter --> Watching: next video ready
ChangingChapter --> CompletedCourse: no more activities
ChangingChapter --> Recovering: page/chapter unstable
Recovering --> Watching: recovered
Recovering --> SafeStop: recovery failed
StoppedAtTest --> ChangingChapter: stopAtTests disabled
ThresholdStop --> ChangingChapter: user continues course
Non è una trascrizione uno-a-uno del codice.
È un modello del comportamento.
Questa distinzione è importante: una state machine serve a descrivere le regole essenziali del sistema, non necessariamente ogni variabile locale.
advance(v) è quasi un transition dispatcherLa funzione centrale è:
async function advance(v) {
All’inizio troviamo diverse guardie:
if (collectingCourseMaterials) {
return;
}
if (courseBatchRunning()) {
return;
}
if (!enabled) {
return;
}
if (busy || v !== lastVideo) {
return;
}
Poi:
busy = true;
e il workflow entra in una regione critica.
Possiamo tradurre questa parte in linguaggio state-machine:
EVENT: ADVANCE_REQUESTED
GUARDS:
- autoplay enabled
- no material collection
- no batch operation
- not already advancing
- requested video is still authoritative
TRANSITION:
WATCHING / DELAYED → VERIFYING_CURRENT_LESSON
Il codice successivo decide poi quale ramo prendere.
busy non è la state machinePotrebbe essere tentante pensare:
busy = false → idle
busy = true → advancing
Ma sarebbe una semplificazione eccessiva.
busy è una reentrancy guard.
Serve a impedire che due avanzamenti entrino contemporaneamente nella stessa sequenza.
Non ci dice però perché l’autoplay è fermo o cosa sta facendo.
Con busy === false potremmo essere:
Quindi:
Un flag può rappresentare una proprietà dello stato senza rappresentare lo stato intero.
Questo è uno dei motivi per cui i sistemi con molti boolean possono diventare difficili da leggere.
Immaginiamo tre flag:
enabled
busy
stopAtTests
Formalmente esistono:
2³ = 8 combinazioni
Con dieci boolean:
2¹⁰ = 1024 combinazioni
Ovviamente molte combinazioni non sono realmente valide.
Per esempio, concettualmente potrebbe non avere senso essere contemporaneamente:
PAUSED
+
ADVANCING
Una state machine esplicita aiuta perché rende molte combinazioni impossibili per costruzione.
Invece di:
enabled = false;
busy = true;
stoppedAtTestContext = ...;
potremmo avere uno stato unico:
paused
con un contesto separato.
Questa distinzione tra finite state e extended state/context è fondamentale.
Una macchina non deve trasformare ogni dato in uno stato.
Per esempio:
watching chapter 5 lesson 2
watching chapter 5 lesson 3
watching chapter 5 lesson 4
non richiede tre stati differenti.
Possiamo avere:
state = WATCHING
con:
context = {
courseCode,
chapterIdentity,
lessonIdentity,
videoElement
}
Nel codice PlumePilot questo ruolo è svolto in parte da playbackContext:
playbackContext = {
videoElement,
courseCode: courseCodeFromUrl(),
lessonNumber: lessonNumberFromUrl(),
identity,
name: lessonName(row),
index,
};
Questo è molto vicino al concetto di extended state.
Lo stato descrive la modalità.
Il contesto contiene i dati necessari a quella modalità.
playbackContext rende stabile l’identità attraverso le transizioniNel Capitolo 03 abbiamo già visto che un nodo DOM può diventare obsoleto.
Qui possiamo reinterpretare lo stesso meccanismo come parte della macchina.
Durante advance(v):
let cur = await recoverPlaybackLesson(v);
Poi viene conservato:
const completedLesson = playbackContext;
Dopo l’attesa:
const completed = await waitForLessonCompletion(cur, v);
il mondo viene verificato nuovamente:
const stableCur = await recoverPlaybackLesson(v);
if (
!enabled ||
v !== lastVideo ||
video() !== v ||
!stableCur ||
playbackLessonRow(completedLesson) !== stableCur
) {
return;
}
Possiamo leggerlo così:
VERIFYING_COMPLETION
│
│ await platform state
▼
REVALIDATE CONTEXT
│
├─ context unchanged → continue transition
└─ context changed → cancel transition
È un pattern estremamente importante.
La macchina non presume che, dopo un’attesa asincrona, si trovi ancora nello stesso mondo.
Prendiamo alcuni esempi.
if (!enabled) return;
if (lastVideo === v) return;
if (busy) return;
if (v !== lastVideo) return;
if (collectingCourseMaterials || courseBatchRunning()) return;
if (test && stopAtTests) {
else if (test && autoCompleteTests) {
if (thresholdReached) {
if (sessionLimitReached) {
Visti insieme, questi if non sono soltanto controllo di flusso.
Sono il transition table implicito dell’autoplay.
Possiamo rappresentare parte del comportamento in una tabella.
| Stato | Evento | Guardia | Nuovo stato / azione |
|---|---|---|---|
WATCHING |
VIDEO_ENDED |
enabled && !smartResumeRunning |
ADVANCE_DELAY |
ADVANCE_DELAY |
TIMER |
enabled |
VERIFYING_COMPLETION |
VERIFYING_COMPLETION |
COMPLETED |
next video exists | OPENING_NEXT_VIDEO |
VERIFYING_COMPLETION |
COMPLETED |
terminal test + stop | STOPPED_AT_TEST |
VERIFYING_COMPLETION |
COMPLETED |
terminal test + auto-complete | COMPLETING_TEST |
COMPLETING_TEST |
SUCCESS |
— | END_OF_CHAPTER |
COMPLETING_TEST |
FAILURE |
— | SAFE_STOP |
END_OF_CHAPTER |
CONTINUE |
threshold reached | THRESHOLD_STOP |
END_OF_CHAPTER |
CONTINUE |
session limit reached | SESSION_LIMIT_STOP |
END_OF_CHAPTER |
CONTINUE |
otherwise | CHANGING_CHAPTER |
STOPPED_AT_TEST |
SETTINGS_CHANGED |
!stopAtTests |
RESUMING |
| qualunque stato attivo | DISABLE |
— | PAUSED |
Una tabella del genere è utile per due motivi.
Primo: rende visibili casi dimenticati.
Secondo: può diventare quasi direttamente una matrice di test.
lastVideo come deduplicazioneIl listener ended contiene:
if (lastVideo === v) return;
lastVideo = v;
Perché?
Perché nei sistemi event-driven è pericoloso assumere che un evento semanticamente importante arrivi esattamente una volta.
Il browser e la piattaforma possono:
PlumePilot trasforma quindi:
VIDEO_ENDED(video X)
VIDEO_ENDED(video X)
in un solo avanzamento logico.
Possiamo vedere lastVideo come una piccola forma di:
event deduplication
oppure, in termini di state machine:
ignora eventi che non producono una transizione valida dallo stato corrente
play corregge lo stato per il riuso del playerC’è però una complicazione.
La piattaforma può riutilizzare lo stesso elemento <video> per un nuovo contenuto.
Per questo il listener play contiene:
if (
lastVideo === v &&
!v.ended &&
(!Number.isFinite(v.duration) || v.currentTime < v.duration - 0.5)
) {
lastVideo = null;
}
Altrimenti avremmo questo errore logico:
video element A finisce
lastVideo = A
piattaforma riusa element A per il video successivo
nuovo ended su A
↓
"è già lastVideo"
↓
evento ignorato per errore
Quindi lo stato deve rappresentare l’identità semantica, non soltanto l’identità dell’oggetto JavaScript.
È lo stesso principio visto con il DOM, applicato ora agli eventi.
Dopo ended, PlumePilot non chiama immediatamente advance(v).
Usa:
timer = setTimeout(() => {
if (enabled) {
advance(v);
}
}, DELAY_MS);
Nella modellazione a stati possiamo rappresentarlo come:
VIDEO_ENDED
↓
WAITING_AFTER_END
↓ after DELAY_MS
VERIFYING_COMPLETION
Questo è un esempio di delayed transition.
Il tempo fa parte del comportamento.
La differenza rispetto a un semplice sleep() è concettuale:
non stiamo solo aspettando; stiamo dicendo che il sistema resta in uno stato per un intervallo, durante il quale altri eventi possono invalidare la transizione.
Infatti allo scadere del timer viene ricontrollato:
if (enabled)
Il timer non congela il mondo.
waitForLessonCompletion() mostra molto bene questa idea.
Quando il video termina, la piattaforma può non aver ancora aggiornato la sidebar.
Quindi non possiamo fare:
video ended → completed
Il modello corretto è:
VIDEO ENDED
↓
AWAITING PLATFORM CONFIRMATION
↓
100% registered
oppure:
VIDEO ENDED
↓
AWAITING PLATFORM CONFIRMATION
↓ timeout
real video end confirmed
↓
continue with safeguard
oppure:
VIDEO ENDED
↓
platform validation failure
↓
SAFE STOP / RECOVERY
Questo è molto più fedele al dominio.
Una state machine applicativa non controlla tutto il mondo.
PlumePilot controlla il proprio stato, ma non controlla:
Questi fattori arrivano alla macchina come eventi esterni.
Possiamo rappresentare la relazione così:
┌────────────────────┐
│ Multiversity │
└──────┬─────────────┘
│
DOM / API / video events
│
▼
┌──────────────────┐
│ Autoplay machine │
└────────┬─────────┘
│ click / API / status
│
▼
Multiversity
La macchina è quindi reattiva.
Non esegue un copione predeterminato: reagisce al mondo.
Arriviamo a uno dei punti più importanti.
Alla fine di un capitolo, PlumePilot controlla:
const test = getEndOfLessonTest(chapter);
Da qui il comportamento può divergere.
if (test && stopAtTests) {
...
stoppedAtTestContext = {
identity: chapterIdentity(chapter),
oldVideo: v,
};
return;
}
Questo corrisponde molto bene a uno stato esplicito:
STOPPED_AT_TEST
Notiamo che viene salvato anche il contesto necessario a riprendere.
else if (test && isEndOfLessonTestCompleted(test)) {
// skip
}
La macchina non entra in un nuovo stato duraturo: attraversa semplicemente la guardia.
else if (test && autoCompleteTests) {
const testCompleted = await completeAutomaticEndOfLessonTest(...);
if (!testCompleted) {
return;
}
}
Concettualmente:
AT_TEST_BOUNDARY
↓
COMPLETING_TEST
↓ success
END_OF_CHAPTER
oppure:
COMPLETING_TEST
↓ failure
SAFE_STOP
Se c’è un test ma nessuna delle guardie precedenti blocca la transizione:
AT_TEST_BOUNDARY → END_OF_CHAPTER
Quattro comportamenti differenti partono dallo stesso evento logico.
È esattamente il tipo di situazione in cui una state machine diventa utile.
Nel Capitolo 06 abbiamo introdotto questa distinzione.
Qui la vediamo in azione.
Nel bridge, la preferenza viene letta da storage.
Poi viene derivato lo stato effettivo.
In content.js:
stopAtTests =
event.data.stopAtTests !== false &&
!autoCompleteTests &&
!chapterLimitEnabled;
Quindi una preferenza salvata può essere vera, ma la guardia runtime può risultare falsa perché un’altra modalità ha priorità.
Questo evita combinazioni contraddittorie.
State-machine thinking ci suggerisce di non pensare a:
stopAtTests = checkbox
ma a:
storedIntent.stopAtTests
+
runtime constraints
↓
effectiveMode
Questo è un modo molto più robusto di modellare configurazioni interdipendenti.
stoppedAtTestContext è particolarmente interessante.
Quando l’autoplay si ferma, non basta sapere:
"sono fermo"
Serve sapere da dove riprendere.
Per questo viene conservato:
{
identity,
oldVideo
}
Quando l’utente disattiva stopAtTests, il cambio impostazioni contiene:
if (wasStoppingAtTests && !stopAtTests) {
const context = stoppedAtTestContext;
stoppedAtTestContext = null;
if (context) {
resumeAfterTestBoundary(context);
} else {
resumeIfVideoAlreadyEnded();
}
}
In linguaggio state-machine:
STATE: STOPPED_AT_TEST
EVENT: STOP_AT_TESTS_DISABLED
ACTION: consume saved context
TRANSITION: RESUMING_AFTER_TEST
È uno degli esempi più puliti del capitolo.
stoppedAtTestContext ci mostra anche un concetto più generale.
Talvolta lo stato corrente non basta.
Per riprendere correttamente serve una parte della history.
Non ci interessa tutta la cronologia del corso.
Ci interessa soltanto:
ultimo punto sicuro da cui continuare
È una forma molto semplice di state history.
La stessa idea appare nei recovery:
currentIdentity
path
attempts
createdAt
Il software conserva il minimo contesto necessario a ricostruire una transizione interrotta.
Dopo aver terminato un capitolo:
const sessionLimitReached = chapterLimitReached(...);
const thresholdReached = autoplayThresholdReached();
Poi:
if (thresholdReached) {
...
return;
}
if (sessionLimitReached) {
...
return;
}
Dal punto di vista della singola sessione autoplay, sono stati terminali:
THRESHOLD_STOP
SESSION_LIMIT_STOP
Ma non sono terminali per l’intera applicazione.
L’utente può riprendere.
Per questo è utile parlare di:
terminal state del workflow corrente
non necessariamente:
fine definitiva dell'applicazione
Il codice gestisce anche:
if (thresholdReached || sessionLimitReached) {
// sound event
}
E il messaggio distingue il caso combinato:
thresholdReached
? sessionLimitReached
? "Soglia del 70% e limite della sessione raggiunti."
: ...
Questo ci ricorda che il sistema possiede anche stato ortogonale.
Due proprietà possono diventare vere contemporaneamente:
thresholdReached = true
sessionLimitReached = true
Una FSM completamente piatta dovrebbe creare una combinazione dedicata.
Uno statechart più ricco può invece modellare regioni o condizioni parallele.
Questo è uno dei punti in cui il termine state machine comincia a diventare più ampio della semplice FSM scolastica.
Una finite state machine semplice assume spesso un solo stato discreto attivo:
A oppure B oppure C
Nei software reali abbiamo frequentemente proprietà indipendenti:
playback = watching
threshold = armed
sessionLimit = active
recovery = none
Appiattire tutto produce molti stati:
WATCHING_THRESHOLD_ARMED_LIMIT_ACTIVE
WATCHING_THRESHOLD_BYPASSED_LIMIT_ACTIVE
WATCHING_THRESHOLD_ARMED_LIMIT_DISABLED
...
Uno statechart permette concetti più ricchi:
Non è necessario introdurre una libreria per beneficiare di questi concetti.
La modellazione da sola può già migliorare il ragionamento.
I recovery di PlumePilot sono un ottimo candidato per essere pensati come una macchina separata.
Per esempio il session-conflict recovery può essere semplificato così:
stateDiagram-v2
[*] --> NoRecovery
NoRecovery --> RecoveryRequested: conflict dialog
RecoveryRequested --> Reloading: confirm platform recovery
Reloading --> Stabilizing: page restored
Stabilizing --> NoRecovery: video stable
Stabilizing --> SafeStop: conflict repeats / timeout
Questa macchina può interagire con l’autoplay principale:
AUTOPLAY.ACTIVE
│
├─ recovery = none → transizioni normali
└─ recovery != none → niente first-incomplete / avanzamenti pericolosi
E infatti startFirstIncompleteDiscovery() controlla:
if (
readChapterRecovery()?.currentIdentity ||
readSessionConflictRecovery() ||
readWatchValidationRecovery()
) {
return;
}
State-machine thinking rende evidente il significato:
la discovery non è una transizione ammessa mentre la recovery submachine non è nello stato
NONE.
L’autoplay verifica anche:
if (collectingCourseMaterials) return;
if (courseBatchRunning()) return;
Nel Capitolo 05 abbiamo visto la concorrenza.
Qui possiamo modellarla così:
COURSE CONTROL OWNER
AUTOPLAY
EXPORT
TURBO TEST
OBJECTIVES
Alcune attività sono incompatibili perché manipolano o interrogano la stessa struttura di corso.
Quindi:
AUTOPLAY transition
guard: courseOwner == AUTOPLAY || courseOwner == NONE
Il progetto non implementa letteralmente questo enum, ma il concetto è utile per capire perché certi controlli esistono.
nextChapter() è una transizione compostaAlla fine del capitolo:
await nextChapter(v, cur);
Non significa semplicemente:
chapter++
Può richiedere:
Quindi CHANGING_CHAPTER è in realtà un compound state.
Possiamo immaginarlo così:
CHANGING_CHAPTER
├── FIND_TARGET
├── OPEN_SECTION
├── OPEN_CHAPTER
├── WAIT_RENDER
├── SELECT_ACTIVITY
└── WAIT_VIDEO
Questa gerarchia ci evita di trasformare la macchina principale in un diagramma illeggibile.
startFirstIncompleteDiscovery() condivide molti strumenti con l’autoplay, ma ha un obiettivo differente.
Non sta semplicemente continuando da una lezione conclusa.
Sta cercando:
il primo target valido secondo una politica di discovery
Per questo conviene pensarlo come una submachine:
IDLE
↓ start
BUILD_OUTLINE
↓
READ_MASTER
↓
SCAN_CANDIDATES
├─ target → OPEN_TARGET
├─ uncertain → VISUAL_VERIFY
├─ cancelled → CANCELLED
└─ none → COMPLETE
Nel Capitolo 08 la studieremo nel dettaglio.
Per ora è sufficiente notare che:
due feature possono condividere API e funzioni senza essere lo stesso workflow.
Questo è un principio architetturale importante.
Nel codice della discovery troviamo casi come:
if (!lesson.ok) {
setResumeDiscoveryStatus(
"Test corrente non verificabile. Avanzamento fermato per sicurezza.",
"fallback",
);
return true;
}
Il principio è:
incertezza su una condizione che potrebbe richiedere stop
↓
non avanzare
Questo è un comportamento fail closed.
Per l’autoplay è preferibile un falso stop a un falso skip quando l’utente ha esplicitamente chiesto di fermarsi ai test.
In termini di state machine:
VERIFY_TEST
├─ confirmed absent → continue
├─ confirmed present → STOPPED_AT_TEST
└─ unknown → SAFE_STOP
Lo stato UNKNOWN non viene trattato come FALSE.
Questo è un vantaggio notevole.
Senza modello, potremmo avere:
if (!test) continue;
Ma !test può significare:
nessun test esiste
oppure:
non siamo riusciti a verificarlo
Sono semanticamente differenti.
Una buona macchina può distinguere:
ABSENT
PRESENT
UNKNOWN
e scegliere transizioni diverse.
Questa idea ricorrerà fortemente nel Capitolo 08.
Una state machine è spesso descritta come:
nextState = transition(currentState, event)
Ma PlumePilot interagisce con side effect reali:
click();fetch();setTimeout();Per questo è utile separare idealmente:
TRANSITION LOGIC
da:
EFFECTS
Per esempio:
STATE = END_OF_CHAPTER
EVENT = CONTINUE
GUARD = thresholdReached
NEXT STATE = THRESHOLD_STOP
ACTION = show notification + publish status + play sound
Il cambio di stato è una decisione.
Il suono è un effetto.
Questa separazione rende i test più semplici.
Questo non è codice presente in PlumePilot e non è una proposta di modifica necessaria.
Serve soltanto a visualizzare il modello.
Potremmo immaginare:
function transition(state, event, context) {
if (event.type === "DISABLE") {
return { state: "paused", context };
}
if (state === "watching" && event.type === "VIDEO_ENDED") {
if (context.discoveryRunning) return { state, context };
return { state: "advanceDelay", context };
}
if (state === "atTestBoundary") {
if (context.stopAtTests) {
return { state: "stoppedAtTest", context };
}
if (context.autoCompleteTests) {
return { state: "completingTest", context };
}
return { state: "endOfChapter", context };
}
return { state, context };
}
Poi gli effetti potrebbero essere eseguiti separatamente.
Il vantaggio sarebbe poter testare:
transition(
"atTestBoundary",
{ type: "DECIDE" },
{ stopAtTests: true, autoCompleteTests: false }
)
senza aprire un browser, una pagina Multiversity o un vero video.
Perché una state machine esplicita ha anche un costo.
Potrebbe richiedere:
Perciò la conclusione non è:
“PlumePilot dovrebbe essere riscritto con XState.”
La conclusione è:
Il modello a stati è uno strumento per capire e progettare il comportamento, indipendentemente dalla sintassi usata per implementarlo.
Questa distinzione è fondamentale.
Se vieni dal backend, puoi confrontare l’autoplay con un workflow applicativo.
Per esempio un ordine e-commerce può essere:
CREATED
→ PAID
→ PREPARING
→ SHIPPED
→ DELIVERED
con deviazioni:
PAYMENT_FAILED
CANCELLED
REFUNDED
L’autoplay ha lo stesso tipo di problema:
WATCHING
→ VIDEO_ENDED
→ VERIFYING
→ NEXT_ACTIVITY
con deviazioni:
STOPPED_AT_TEST
RECOVERING
THRESHOLD_STOP
SESSION_LIMIT_STOP
SAFE_STOP
La differenza è che il dominio è una pagina web dinamica invece di un database di ordini.
Ma il problema logico è molto simile.
Una guardia decide se una singola transizione può avvenire.
Un invariant descrive invece una proprietà che dovrebbe restare vera in tutti gli stati rilevanti.
Per l’autoplay possiamo formulare alcuni invariant utili.
Se enabled == false, PlumePilot non deve selezionare automaticamente una nuova attività.
Non devono esistere due avanzamenti automatici concorrenti.
busy contribuisce a garantirlo.
Se stopAtTests è effettivamente attivo e un test non è verificabile,
PlumePilot non deve oltrepassare silenziosamente il confine.
Dopo un await, il target deve essere ancora coerente con il contesto che ha iniziato la transizione.
Un recovery non deve generare un loop infinito di reload.
Questi invariant sono molto utili anche nei test di regressione.
Una macchina ben descritta suggerisce direttamente i casi da verificare.
GIVEN state = PAUSED
WHEN VIDEO_ENDED
THEN nessuna navigazione automatica
GIVEN lo stesso video ha già generato la transizione
WHEN arriva un altro ended sullo stesso target logico
THEN niente secondo advance
GIVEN stopAtTests = true
AND il capitolo termina con un test
WHEN l'ultimo video viene completato
THEN state = STOPPED_AT_TEST
AND il capitolo successivo non viene aperto
GIVEN state = STOPPED_AT_TEST
WHEN stopAtTests diventa false
THEN riprendi dal contesto salvato
Questo è esattamente il tipo di regressione risolto in passato.
GIVEN autoCompleteTests = true
WHEN il test non può essere completato in modo verificato
THEN SAFE_STOP
GIVEN threshold70 reached
AND chapter session limit reached
WHEN il capitolo termina
THEN niente nextChapter
AND stato terminale coerente
AND messaggio combinato corretto
GIVEN advance() sta aspettando completion
WHEN l'utente cambia lezione o il DOM viene ricostruito
THEN la vecchia transizione viene annullata
Questa è una delle regressioni più importanti che abbiamo studiato.
Letto con questa lente, il changelog di PlumePilot diventa quasi una storia di transizioni mancanti.
Arriva il ramo:
AT_TEST_BOUNDARY → COMPLETING_TEST
con una regola importante:
se non posso verificare il completamento → stop
Il test boundary diventa più formale:
current test present / absent / unknown
con comportamento fail-closed.
Viene corretta una transizione che mancava:
STOPPED_AT_TEST
--disable stopAtTests-->
RESUME
Compare una vera submachine di recovery per il conflitto di sessione.
La discovery della prima attività incompleta diventa un workflow autonomo con cancellazione e fallback.
Compare:
END_OF_CHAPTER → SESSION_LIMIT_STOP
Vengono migliorate le transizioni relative ad accordion chiusi, handoff dagli Obiettivi e routing fra capitoli.
Vista così, una regressione non è semplicemente:
"funzione X rotta"
ma spesso:
"mancava o era sbagliata una transizione fra due stati reali del sistema"
Questo è un principio molto potente.
Prendiamo alcuni bug incontrati nello sviluppo.
Il vecchio modello implicito assumeva:
WAITING_COMPLETION → stesso DOM ancora disponibile
Ma il mondo reale permetteva:
WAITING_COMPLETION → SIDEBAR_CHANGED
La transizione non era stata sufficientemente modellata.
La correzione ha introdotto re-resolution e revalidation.
Il sistema aveva:
WATCHING → STOPPED_AT_TEST
ma la transizione inversa non era affidabile.
La correzione ha introdotto esplicitamente il resume context.
Il modello ingenuo diceva:
videoElement == lastVideo → evento duplicato
Il modello reale richiedeva:
stesso elemento DOM != necessariamente stesso video logico
Di nuovo: identità e transizione.
Possiamo ora rappresentare una versione più ricca.
stateDiagram-v2
[*] --> Paused
Paused --> Watching: ENABLE
Watching --> Paused: DISABLE
Watching --> WaitingAfterEnd: VIDEO_ENDED / remember lastVideo
WaitingAfterEnd --> Paused: DISABLE
WaitingAfterEnd --> VerifyingCompletion: DELAY_ELAPSED [enabled]
VerifyingCompletion --> SafeStop: completion invalid
VerifyingCompletion --> Watching: context changed / cancel stale transition
VerifyingCompletion --> NextVideo: next video in same chapter
VerifyingCompletion --> TestDecision: end of chapter + test
VerifyingCompletion --> EndDecision: end of chapter + no test
NextVideo --> Watching: new video selected
TestDecision --> StoppedAtTest: [stopAtTests]
TestDecision --> CompletingTest: [autoCompleteTests]
TestDecision --> EndDecision: [ignore test]
CompletingTest --> EndDecision: SUCCESS
CompletingTest --> SafeStop: FAILURE
StoppedAtTest --> ResumeAfterTest: STOP_AT_TESTS_DISABLED
ResumeAfterTest --> ChangingChapter: delay elapsed
EndDecision --> ThresholdStop: [thresholdReached]
EndDecision --> SessionLimitStop: [sessionLimitReached]
EndDecision --> ChangingChapter: otherwise
ChangingChapter --> Watching: target video ready
ChangingChapter --> Recovering: rendering/session problem
ChangingChapter --> CourseComplete: no target
Recovering --> Watching: recovery success
Recovering --> SafeStop: recovery exhausted
ThresholdStop --> ChangingChapter: CONTINUE_70
SessionLimitStop --> ChangingChapter: RESUME_SESSION
Il diagramma non sostituisce il codice.
Fa emergere le sue regole.
Non tutto merita uno stato.
Per esempio:
percent = 42
percent = 43
percent = 44
non sono tre stati della macchina.
Sono dati nel contesto.
Allo stesso modo:
chapterLimit = 5
è un parametro.
Diventa rilevante come stato soltanto quando la condizione cambia semanticamente:
limit not reached
→
limit reached
Una buona regola è:
Modella come stato ciò che cambia il comportamento ammesso del sistema.
Proviamo con variabili reali.
enabledInfluenza direttamente le transizioni possibili.
Può essere visto come stato/modalità globale.
busyÈ una guardia di reentrancy.
Non descrive da sola una modalità di dominio completa.
playbackContextÈ contesto/extended state.
stopAtTestsÈ una policy che modifica le transizioni al test boundary.
stoppedAtTestContextÈ history/context associato allo stato STOPPED_AT_TEST.
chapterLimitSession.completedÈ un contatore nel contesto.
Quando supera una soglia produce una transizione verso SESSION_LIMIT_STOP.
lastVideoÈ memoria di deduplicazione/event identity.
sessionStorageÈ stato di una submachine che deve sopravvivere al reload.
Questo tipo di classificazione aiuta moltissimo a capire codice complesso.
Una delle idee più utili da portarsi dietro è che un’applicazione non deve avere una gigantesca state machine.
PlumePilot si presta meglio a una composizione concettuale:
AutoplayMachine
├── PlaybackMachine
├── TestBoundaryMachine
├── ChapterNavigationMachine
├── RecoveryMachine
└── FirstIncompleteMachine
OperationCoordinator
├── Export
├── Turbo
└── Objectives
Le macchine comunicano attraverso eventi o condizioni condivise.
Questa decomposizione è molto simile alla separazione in servizi nel backend: ogni componente possiede un pezzo di comportamento coerente.
È particolarmente utile quando il sistema presenta:
L’autoplay di PlumePilot soddisfa diversi di questi criteri.
Questo non implica che un refactor sia obbligatorio oggi.
Implica che pensare in termini di state machine è molto adatto al problema.
Nel mondo JavaScript esistono librerie come XState che permettono di dichiarare formalmente stati, eventi, guardie e azioni.
Un esempio didattico molto semplice potrebbe essere:
const machine = createMachine({
initial: "paused",
states: {
paused: {
on: { ENABLE: "watching" },
},
watching: {
on: {
DISABLE: "paused",
VIDEO_ENDED: "verifyingCompletion",
},
},
verifyingCompletion: {},
},
});
Non serve adottare XState in PlumePilot per studiarlo.
Può però essere utile come laboratorio visuale per imparare i concetti.
La documentazione Stately/XState distingue esplicitamente:
Sono gli stessi concetti che stiamo applicando manualmente al nostro progetto.
Uno dei vantaggi principali di una state machine è poter discutere il comportamento senza parlare immediatamente di DOM o API.
Per esempio:
QUESTION
Cosa deve succedere se:
- il video è terminato;
- il test è presente;
- stopAtTests è true;
- autoCompleteTests è false;
- nel frattempo l'utente mette in pausa l'autoplay?
Prima definiamo la regola:
DISABLE ha priorità → PAUSED
Poi decidiamo come implementarla.
Questo separa:
policy
da:
mechanism
Una distinzione classica e molto utile nel software engineering.
Anche i log diventano più utili se pensati come transizioni.
Oggi PlumePilot registra messaggi come:
Video ended; advancing.
Lesson registered as 100%.
End-of-lesson test boundary reached.
End of chapter reached.
Autoplay session limit reached.
Quasi tutti possono essere reinterpretati come:
WATCHING → VERIFYING
VERIFYING → TEST_BOUNDARY
TEST_BOUNDARY → STOPPED
END_OF_CHAPTER → SESSION_LIMIT_STOP
Un logging strutturato ideale potrebbe perfino registrare:
{
from: "verifyingCompletion",
event: "LESSON_COMPLETED",
to: "testBoundary",
courseCode,
chapterKey
}
Ancora una volta: non è necessario modificarlo oggi.
Ma è un buon esempio di come la modellazione migliori anche l’osservabilità.
Supponiamo che un utente dica:
“Se disattivo Fermati ai test dopo che PlumePilot si è fermato, non riparte.”
Un debug puramente procedurale potrebbe richiedere di seguire molte funzioni.
Con il modello a stati chiediamo subito:
Qual è lo stato iniziale?
STOPPED_AT_TEST
Qual è l'evento?
STOP_AT_TESTS_DISABLED
Quale transizione ci aspettiamo?
STOPPED_AT_TEST → RESUMING
La transizione viene emessa?
La guardia la blocca?
Il context necessario esiste ancora?
L'effetto di resume fallisce?
Abbiamo ristretto enormemente lo spazio del problema.
Il coverage tradizionale può dirci:
questa riga è stata eseguita
Ma per un workflow spesso vogliamo sapere:
questa transizione è stata verificata
Per esempio, potremmo avere copertura di tutte le righe di:
if (wasStoppingAtTests && !stopAtTests) { ... }
senza aver mai testato la sequenza completa:
video ends
→ test boundary
→ stop
→ popup setting changes
→ context restored
→ next chapter opens
La transition coverage è un modo più adatto di pensare a questi workflow.
Quando incontri una feature complessa, prova a scrivere cinque cose.
Quali modalità cambiano davvero il comportamento?
Cosa può succedere dall'esterno o dall'interno?
Quali condizioni devono essere vere perché la transizione avvenga?
Quali side effect vengono eseguiti?
Quali dati dobbiamo conservare senza trasformarli in stati separati?
Applicato a PlumePilot:
STATE = STOPPED_AT_TEST
EVENT = SETTINGS_CHANGED
GUARD = stopAtTests became false
ACTION = schedule resume
CONTEXT = chapter identity + previous video
Questa singola riga descrive parecchio codice.
Modello di comportamento composto da un insieme finito di stati e transizioni fra essi.
Modalità corrente che determina quali eventi e comportamenti sono validi.
Qualcosa che è accaduto e che può provocare una transizione.
Passaggio da uno stato a un altro.
Condizione che abilita o impedisce una transizione.
Operazione eseguita come conseguenza di una transizione: click, fetch, storage, notifica, ecc.
Dati associati alla macchina che non devono diventare ciascuno uno stato discreto.
Transizione abilitata dopo un intervallo temporale, purché il contesto sia ancora valido.
Informazione sufficiente a ricordare da dove o come riprendere un workflow.
Crescita combinatoria del numero di stati quando molte proprietà indipendenti vengono appiattite in una singola FSM.
Stato composto da sottostati, utile per nascondere dettagli di un workflow complesso.
Aspetti indipendenti dello stato che possono essere attivi contemporaneamente.
In caso di incertezza, scegliere la transizione più conservativa invece di procedere assumendo condizioni favorevoli.
Proprietà che deve restare vera attraverso tutte le transizioni ammesse.
Verifica non soltanto delle righe di codice, ma delle combinazioni stato-evento-transizione significative.
Per una descrizione formale delle state machine e dei loro elementi, l’Object Management Group tratta le state machine come grafi di stati e transizioni attivate da eventi, con guardie e attività associate.
Per un’introduzione più pratica nel mondo JavaScript, la documentazione Stately/XState è utile perché separa chiaramente stati, eventi, transizioni, guardie, azioni e context, e permette anche di visualizzare una macchina.
Risorse consigliate:
Stately — What are state machines and statecharts?
https://stately.ai/docs/state-machines-and-statecharts
Stately — States and transitions
https://stately.ai/docs/editor-states-and-transitions
Stately — Guards
https://stately.ai/docs/guards
Stately — XState documentation
https://stately.ai/docs
Object Management Group — UML / State Machines
https://www.omg.org/spec/UML/
Non serve imparare subito la sintassi di XState. Il valore principale, per questo libro, è imparare a vedere il comportamento come un sistema di stati e transizioni.
Prova a classificare queste variabili come state, guard, context o effect-related state:
enabled
busy
lastVideo
playbackContext
chapterLimitSession.completed
stoppedAtTestContext
Non esiste necessariamente una sola risposta perfetta: conta motivare la scelta.
Disegna la sola submachine dei test:
NO_TEST
TEST_PRESENT
STOPPED_AT_TEST
COMPLETING_TEST
TEST_COMPLETED
TEST_FAILURE
Poi chiediti quali stati siano davvero necessari e quali siano invece eventi o condizioni.
Considera questa sequenza:
1. il video termina;
2. parte il delay;
3. l'utente mette in pausa PlumePilot;
4. scade il timer.
Quale guardia impedisce l’avanzamento?
Perché è importante ricontrollarla alla fine del timer e non soltanto quando il timer viene creato?
Immagina di rimuovere busy.
Costruisci una timeline in cui due chiamate advance(v) entrano quasi contemporaneamente.
Quali transizioni duplicate potrebbero verificarsi?
Prendi una regressione reale di PlumePilot e descrivila soltanto con:
stato iniziale
→ evento
→ transizione attesa
→ transizione osservata
È un esercizio molto efficace per imparare a fare debugging a livello di comportamento.
L’autoplay di PlumePilot può essere letto come una lunga sequenza di funzioni, ma questa lettura nasconde la struttura fondamentale.
Il comportamento reale è governato da:
stati
+
eventi
+
guardie
+
transizioni
+
context
+
side effect
Il codice attuale rappresenta questa macchina in modo implicito attraverso variabili, if, timer, callback, recovery e funzioni asincrone.
Ricostruirla esplicitamente ci permette di capire meglio:
await richiedono revalidation;La lezione più importante può essere riassunta così:
Quando molti
ifdipendono da ciò che è successo prima, da ciò che sta succedendo ora e da quali eventi sono ammessi dopo, probabilmente stai già implementando una state machine — anche se non l’hai chiamata così.
E una seconda lezione:
Una buona modellazione a stati non serve a rendere il codice più “accademico”: serve a rendere esplicite le transizioni che il sistema può e non può compiere.
Nel Capitolo 08 prenderemo una delle submachine più interessanti emerse qui:
Seguiremo l’evoluzione reale dell’algoritmo:
DOM scan
↓
cache
↓
ricerca globale
↓
master call
↓
capitoli <100%
↓
verifica dettagliata
↓
fallback visuale
e affronteremo un problema molto più generale di PlumePilot:
Come si prende una decisione affidabile quando nessuna singola fonte è perfettamente autorevole, completa e aggiornata nello stesso momento?
← 06 — Stato, cache e storage · Indice · 08 — Prima attività incompleta →