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 }