/ src / lightspeed / testing.gleam
testing.gleam
  1  //// Helpers for deterministic runtime tests.
  2  
  3  import gleam/list
  4  import gleam/option.{type Option, None, Some}
  5  import lightspeed/agent/isa
  6  
  7  /// A deterministic trace of instructions emitted by a test run.
  8  pub type Trace {
  9    Trace(opcodes: List(String))
 10  }
 11  
 12  /// Serializable log entry for one executed instruction.
 13  pub type LogEntry {
 14    LogEntry(
 15      step: Int,
 16      instruction: isa.Instruction,
 17      opcode: String,
 18      summary: String,
 19    )
 20  }
 21  
 22  /// Patch output collected by the deterministic interpreter.
 23  pub type PatchRecord {
 24    PatchRecord(target: String, html: String)
 25  }
 26  
 27  /// Event output collected by the deterministic interpreter.
 28  pub type EventRecord {
 29    EventRecord(name: String, payload: String)
 30  }
 31  
 32  /// Deterministic interpreter state for ISA simulation.
 33  pub type SimulationState {
 34    SimulationState(
 35      mounted_route: Option(String),
 36      rendered_views_rev: List(String),
 37      patches_rev: List(PatchRecord),
 38      pushed_events_rev: List(EventRecord),
 39      subscriptions_rev: List(String),
 40      navigation_to: Option(String),
 41      shutdown_reason: Option(String),
 42      telemetry_rev: List(String),
 43      errors_rev: List(String),
 44    )
 45  }
 46  
 47  /// Build a trace from an instruction list.
 48  pub fn trace(instructions: List(isa.Instruction)) -> Trace {
 49    instructions
 50    |> list.map(isa.opcode)
 51    |> Trace
 52  }
 53  
 54  /// Extract opcodes from a trace.
 55  pub fn opcodes(trace: Trace) -> List(String) {
 56    trace.opcodes
 57  }
 58  
 59  /// Run an ISA program through the deterministic test interpreter.
 60  pub fn run(program: List(isa.Instruction)) -> SimulationState {
 61    let #(state, _) = run_with_log(program)
 62    state
 63  }
 64  
 65  /// Run an ISA program and return a replayable instruction log.
 66  pub fn run_with_log(
 67    program: List(isa.Instruction),
 68  ) -> #(SimulationState, List(LogEntry)) {
 69    run_loop(program, new_state(), [], 0)
 70  }
 71  
 72  /// Replay a prior log into a deterministic interpreter state.
 73  pub fn replay(log: List(LogEntry)) -> SimulationState {
 74    log
 75    |> instructions
 76    |> run
 77  }
 78  
 79  /// Extract instructions from a log.
 80  pub fn instructions(log: List(LogEntry)) -> List(isa.Instruction) {
 81    log
 82    |> list.map(fn(entry) { entry.instruction })
 83  }
 84  
 85  /// Route from the latest mount instruction, when mounted.
 86  pub fn mounted_route(state: SimulationState) -> Option(String) {
 87    state.mounted_route
 88  }
 89  
 90  /// Rendered view ids in execution order.
 91  pub fn rendered_views(state: SimulationState) -> List(String) {
 92    list.reverse(state.rendered_views_rev)
 93  }
 94  
 95  /// Patches in execution order.
 96  pub fn patches(state: SimulationState) -> List(PatchRecord) {
 97    list.reverse(state.patches_rev)
 98  }
 99  
100  /// Pushed events in execution order.
101  pub fn pushed_events(state: SimulationState) -> List(EventRecord) {
102    list.reverse(state.pushed_events_rev)
103  }
104  
105  /// Subscriptions currently active at end of simulation.
106  pub fn subscriptions(state: SimulationState) -> List(String) {
107    list.reverse(state.subscriptions_rev)
108  }
109  
110  /// Latest navigation target, when present.
111  pub fn navigation(state: SimulationState) -> Option(String) {
112    state.navigation_to
113  }
114  
115  /// Shutdown reason, when a shutdown instruction was executed.
116  pub fn shutdown_reason(state: SimulationState) -> Option(String) {
117    state.shutdown_reason
118  }
119  
120  /// Telemetry messages in execution order.
121  pub fn telemetry(state: SimulationState) -> List(String) {
122    list.reverse(state.telemetry_rev)
123  }
124  
125  /// Validation errors in execution order.
126  pub fn errors(state: SimulationState) -> List(String) {
127    list.reverse(state.errors_rev)
128  }
129  
130  fn new_state() -> SimulationState {
131    SimulationState(
132      mounted_route: None,
133      rendered_views_rev: [],
134      patches_rev: [],
135      pushed_events_rev: [],
136      subscriptions_rev: [],
137      navigation_to: None,
138      shutdown_reason: None,
139      telemetry_rev: [],
140      errors_rev: [],
141    )
142  }
143  
144  fn run_loop(
145    program: List(isa.Instruction),
146    state: SimulationState,
147    log_rev: List(LogEntry),
148    step: Int,
149  ) -> #(SimulationState, List(LogEntry)) {
150    case program {
151      [] -> #(state, list.reverse(log_rev))
152      [instruction, ..rest] -> {
153        let state = apply_instruction(state, instruction)
154        let entry =
155          LogEntry(
156            step: step,
157            instruction: instruction,
158            opcode: isa.opcode(instruction),
159            summary: isa.describe(instruction),
160          )
161        run_loop(rest, state, [entry, ..log_rev], step + 1)
162      }
163    }
164  }
165  
166  fn apply_instruction(
167    state: SimulationState,
168    instruction: isa.Instruction,
169  ) -> SimulationState {
170    let state = append_telemetry(state, isa.describe(instruction))
171  
172    case instruction {
173      isa.Mount(route, _) ->
174        case state.mounted_route {
175          None -> SimulationState(..state, mounted_route: Some(route))
176          Some(_) -> append_error(state, "duplicate mount")
177        }
178  
179      isa.Render(view_id) ->
180        case state.mounted_route {
181          None -> append_error(state, "render before mount")
182          Some(_) ->
183            SimulationState(..state, rendered_views_rev: [
184              view_id,
185              ..state.rendered_views_rev
186            ])
187        }
188  
189      isa.Patch(target, html) ->
190        case state.mounted_route {
191          None -> append_error(state, "patch before mount")
192          Some(_) -> {
193            let patch = PatchRecord(target: target, html: html)
194            SimulationState(..state, patches_rev: [patch, ..state.patches_rev])
195          }
196        }
197  
198      isa.PushEvent(name, payload) -> {
199        let event = EventRecord(name: name, payload: payload)
200        SimulationState(..state, pushed_events_rev: [
201          event,
202          ..state.pushed_events_rev
203        ])
204      }
205  
206      isa.Navigate(to) -> SimulationState(..state, navigation_to: Some(to))
207  
208      isa.Subscribe(topic) ->
209        case contains_topic(topic, state.subscriptions_rev) {
210          True -> state
211          False ->
212            SimulationState(..state, subscriptions_rev: [
213              topic,
214              ..state.subscriptions_rev
215            ])
216        }
217  
218      isa.Unsubscribe(topic) -> {
219        let subscriptions = remove_topic(topic, state.subscriptions_rev)
220        SimulationState(..state, subscriptions_rev: subscriptions)
221      }
222  
223      isa.Shutdown(reason) ->
224        SimulationState(..state, shutdown_reason: Some(reason))
225    }
226  }
227  
228  fn append_telemetry(
229    state: SimulationState,
230    message: String,
231  ) -> SimulationState {
232    SimulationState(..state, telemetry_rev: [message, ..state.telemetry_rev])
233  }
234  
235  fn append_error(state: SimulationState, message: String) -> SimulationState {
236    SimulationState(..state, errors_rev: [message, ..state.errors_rev])
237  }
238  
239  fn contains_topic(topic: String, topics: List(String)) -> Bool {
240    case topics {
241      [] -> False
242      [entry, ..rest] ->
243        case entry == topic {
244          True -> True
245          False -> contains_topic(topic, rest)
246        }
247    }
248  }
249  
250  fn remove_topic(topic: String, topics: List(String)) -> List(String) {
251    case topics {
252      [] -> []
253      [entry, ..rest] ->
254        case entry == topic {
255          True -> remove_topic(topic, rest)
256          False -> [entry, ..remove_topic(topic, rest)]
257        }
258    }
259  }