02.03.01 — Le type Result[T]

02.03.01 — Le type Result[T]#

Fichier source : src/ocarina/railway/result.py — zéro dépendance.

Code#

from dataclasses import dataclass, field
from typing import TypeGuard, final


class _BaseResult:
    """Base class ensuring both Ok and Fail have error attribute for type narrowing."""
    error: Exception | None


@final
@dataclass(frozen=True)
class Ok[T](_BaseResult):
    value: T
    error: None = None


@final
@dataclass(frozen=True)
class Fail(_BaseResult):
    error: Exception = field(default_factory=lambda: Exception("Unknown error"))


type Result[T] = Ok[T] | Fail


def is_ok[T](result: Result[T]) -> TypeGuard[Ok[T]]:
    return isinstance(result, Ok)


def is_fail[T](result: Result[T]) -> TypeGuard[Fail]:
    return isinstance(result, Fail)

Six décisions de design à décortiquer#

1. _BaseResult comme parent commun#

class _BaseResult:
    error: Exception | None

But unique : permettre à mypy d’accéder à result.error sans narrowing préalable. Sans cette base, on devrait écrire :

02.03.02 — La machine à états du builder

02.03.02 — La machine à états du builder#

Fichier source : src/ocarina/dsl/testing_with_railway/internals/action_chain.py

C’est ici qu’est implémentée toute la mécanique ROP : le passage par quatre états successifs typés, qui interdit littéralement (au sens du type-checker) toute fantaisie syntaxique. Le DSL est aussi son propre système immunitaire.

Vue d’ensemble#

                      ┌─────────────────┐
   start(action)  →   │  ActionStart[T] │     un Action[T] = Thunk[Result[T]]
                      └────────┬────────┘
                               │ .failure(failure_handler)
                               ▼
                      ┌─────────────────┐
                      │ ActionFailure[T]│
                      └────────┬────────┘
                               │ .success(success_handler)
                               ▼
                      ┌─────────────────┐
                      │ ActionSuccess[T]│
                      └────────┬────────┘
                               │ .execute()   ── exécute action(), fire le handler
                               ▼
                      ┌─────────────────┐
                      │ ActionChain[T]  │     contient (has_failed, result)
                      └──────┬──────────┘
                             │ .then(next_action_or_start)
              ┌──────────────┴──────────────┐
              │                             │
   has_failed=True                   has_failed=False
              │                             │
              ▼                             ▼
   ┌──────────────────┐         ┌──────────────────┐
   │ NeutralAction-   │         │ ActionStart[T]   │  → cycle recommence
   │ Start[T]         │         │  (l'action suiv. │
   │ (rail d'échec :  │         │   sera exécutée) │
   │  toute la chaîne │         └──────────────────┘
   │  devient no-op,  │
   │ tout en gardant  │
   │ l'API fluide)    │
   └──────────────────┘
              │
              ▼ (.failure → .success → .execute, tout en no-op)
   ┌──────────────────┐
   │  ActionChain[T]  │   has_failed=True, result=<le Fail accumulé>
   └──────────────────┘

Les types Action, FailureHandler, SuccHandler#

type Action[T]        = Thunk[Result[T]]      # () -> Result[T]
type FailureHandler   = Callable[[Exception], None]
type SuccHandler      = Effect                # () -> None
  • Action[T] est un Thunk. Cela veut dire qu’au moment où ActionStart(action) est appelé, rien n’est exécuté. L’action est juste capturée.
  • FailureHandler reçoit l’exception, mais ne reçoit pas le Fail ni le Result. C’est volontaire : 99% des handlers veulent tracer l’exception, prendre un screenshot, et c’est tout.
  • SuccHandler est un Effect (sans argument). Idem : un handler de succès log un message statique, fait un screenshot, pas besoin du résultat.

Typestate Pattern : pourquoi on ne peut pas se tromper#

L’enchaînement est strictement linéaire. Chaque méthode renvoie un type différent, qui n’expose que la suite logique de l’enchaînement :

02.03.03 — Le rail d'échec : NeutralAction*

02.03.03 — Le rail d’échec : NeutralAction*#

Cas particulier du builder : que se passe-t-il quand on enchaîne une action sur un ActionChain qui a déjà échoué ?

Pourquoi des classes Neutral plutôt qu’un if côté utilisateur#

Lorsqu’on enchaîne une action sur un ActionChain qui a déjà échoué, on bascule dans trois classes Neutral qui acceptent la même API, mais ne font rien.

Imaginons que le DSL n’ait pas cette mécanique.
Le code utilisateur ressemblerait à :

02.03.04 — chain_actions et le ChainRunner

02.03.04 — chain_actions et le ChainRunner#

Fichier source : src/ocarina/dsl/testing_with_railway/chain_actions.py

C’est la primitive qui rend le DSL « plat ». Sans elle, un scénario à N pas serait un escalier de .then(...).failure(...).success(...).execute(). Avec elle, c’est une liste.

« Parenthesis hell »#

Solution :

# Flat, readable syntax
runner = chain_actions(
    action1.failure(h1).success(h2),
    action2.failure(h3).success(h4),
    action3.failure(h5).success(h6)
)
chain = runner.run()  # Execute when ready

Benefits :

  • Lazy evaluation : Build chain without executing
  • Flat syntax : No deep nesting
  • Automatic short-circuiting : Stops on first failure
  • Composability : ChainRunner is a value

(Extrait de docstring.)

02.03.05 — create_act et ses hooks

02.03.05 — create_act et ses hooks#

Fichier source : src/ocarina/dsl/testing_with_railway/constructors/create_act.py

Cette fonction est la primitive de bas niveau qui crée un constructeur de pas de test. Elle est appelée par l’utilisateur une fois, pour créer un verbe act propre au projet. Selon la complexité du projet, on peut envisager plusieurs act distincts avec des hooks différents, mais aucun cas d’usage encore observé pour ça, donc on préconise de n’en créer qu’un seul. Ce n’est pas un singleton pour autant : c’est juste une convention.

02.03.06 — drive_page

02.03.06 — drive_page#

Fichier source : src/ocarina/opinionated/dsl/drive_page.py

Code#

def drive_page(
    first: ActionSuccess[TPOM], *rest: ActionSuccess[TPOM]
) -> ChainRunner[TPOM]:
    return chain_actions(first, *rest)

C’est exactement chain_actions.

Pourquoi cet alias ?#

1. Sémantique : « je prends le contrôle d’une page »#

Citation du Holy Book (Premiers scénarios) :

drive_page exprime que l’on prend le contrôle d’une page. Toute transition devient explicite par l’ouverture d’un nouveau drive_page.

return [
    drive_page(
        act(on_homepage, open_homepage)...,
        act(on_homepage, verify_homepage)...,
        act(on_homepage, click_cta)...,
    ),                                          # ⬅ fermeture du contrôle de la homepage
    drive_page(                                 # ⬅ ouverture du contrôle de la page suivante
        act(on_target_page, verify_target_page)...,
    ),
]

2. Discipline#

Mélanger des actes sur deux POMs différents dans un même drive_page devient une erreur mypy (cf. 02-action-chain-states.md, section « narrowing par act() »). Cela force concrètement à respecter la sémantique.

02.03.07 — match_page / when

02.03.07 — match_pagewhen#

Fichier source : src/ocarina/dsl/testing_with_railway/match_page.py

Ajouté après coup au framework. Le Holy Book précise : « match_page et when ont été ajoutés après coup, l’Igoristan était tellement aléatoire que le cas d’usage s’est imposé de lui-même. Leur implémentation a été simple, preuve de la flexibilité de la grammaire : d’autres structures analogues pourraient très bien suivre. »

Le problème#

Certaines pages d’une application peuvent être rendues différemment :