dogfood 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/dogfood +5 -0
- data/lib/dogfood/cli.rb +174 -0
- data/lib/dogfood/day_generator.rb +95 -0
- data/lib/dogfood/dsl/compiler.rb +253 -0
- data/lib/dogfood/dsl/evaluator.rb +211 -0
- data/lib/dogfood/dsl/explain_renderer.rb +172 -0
- data/lib/dogfood/dsl/schema.rb +200 -0
- data/lib/dogfood/ledger.rb +29 -0
- data/lib/dogfood/pack.rb +65 -0
- data/lib/dogfood/railtie.rb +20 -0
- data/lib/dogfood/randomness.rb +37 -0
- data/lib/dogfood/renderer.rb +40 -0
- data/lib/dogfood/scheduler.rb +47 -0
- data/lib/dogfood/step.rb +19 -0
- data/lib/dogfood/story_base.rb +66 -0
- data/lib/dogfood/story_builder.rb +23 -0
- data/lib/dogfood/templates/day_log.md.erb +44 -0
- data/lib/dogfood/test/smoke.rb +20 -0
- data/lib/dogfood/version.rb +5 -0
- data/lib/dogfood.rb +26 -0
- data/lib/generators/dogfood/install/install_generator.rb +30 -0
- metadata +110 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
class Randomness
|
|
5
|
+
attr_reader :seed
|
|
6
|
+
|
|
7
|
+
def initialize(seed: nil)
|
|
8
|
+
@seed = seed || Random.new_seed
|
|
9
|
+
@random = Random.new(@seed)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def branch(weights)
|
|
13
|
+
total = weights.values.sum.to_f
|
|
14
|
+
threshold = @random.rand * total
|
|
15
|
+
cumulative = 0.0
|
|
16
|
+
|
|
17
|
+
weights.each do |key, weight|
|
|
18
|
+
cumulative += weight
|
|
19
|
+
return key if threshold < cumulative
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
weights.keys.last
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def pick(collection)
|
|
26
|
+
collection.sample(random: @random)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sample(collection, count)
|
|
30
|
+
collection.sample(count, random: @random)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rand(range = nil)
|
|
34
|
+
range ? @random.rand(range) : @random.rand
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Dogfood
|
|
6
|
+
class Renderer
|
|
7
|
+
TEMPLATE_PATH = File.expand_path("templates/day_log.md.erb", __dir__)
|
|
8
|
+
|
|
9
|
+
def initialize(result)
|
|
10
|
+
@result = result
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
ERB.new(template, trim_mode: "-").result(binding)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def template
|
|
20
|
+
File.read(TEMPLATE_PATH)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render_story(story)
|
|
24
|
+
lines = []
|
|
25
|
+
lines << "#### #{story[:id]}"
|
|
26
|
+
lines << "**Status:** #{story[:state][:status]}"
|
|
27
|
+
lines << "**Substatus:** #{story[:state][:substatus] || '-'}"
|
|
28
|
+
story[:steps].each do |step|
|
|
29
|
+
lines << "#{step.number}. **#{step.actor}** — #{step.event}"
|
|
30
|
+
lines << " - **Action:** #{step.action}"
|
|
31
|
+
lines << " - **Notes:** #{step.notes}" unless step.notes.to_s.empty?
|
|
32
|
+
end
|
|
33
|
+
lines.join("\n") + "\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def escape_table_cell(text)
|
|
37
|
+
text.to_s.gsub("|", "\\|").gsub("\n", "<br>")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
class Scheduler
|
|
5
|
+
DEFAULT_DELAYS = {
|
|
6
|
+
parts_arrival: { same_day: 0.40, next_day: 0.40, two_to_three_days: 0.15, next_week: 0.05 },
|
|
7
|
+
customer_approval: { same_day: 0.60, next_day: 0.25, two_to_three_days: 0.10, next_week: 0.05 },
|
|
8
|
+
payment: { same_day: 0.70, next_week: 0.20, net_30: 0.10 },
|
|
9
|
+
core_return: { next_day: 0.70, two_to_three_days: 0.30 },
|
|
10
|
+
labor_completion: { same_day: 0.85, next_day: 0.15 }
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(rng, delays: {})
|
|
14
|
+
@rng = rng
|
|
15
|
+
@delays = DEFAULT_DELAYS.merge(delays)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def schedule(event)
|
|
19
|
+
distribution = @delays.fetch(event) { raise ArgumentError, "Unknown event: #{event}" }
|
|
20
|
+
delay_days = resolve_delay(distribution)
|
|
21
|
+
{ delay_days: delay_days, event: event }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve_delay(distribution)
|
|
27
|
+
total = distribution.values.sum.to_f
|
|
28
|
+
threshold = @rng.rand * total
|
|
29
|
+
cumulative = 0.0
|
|
30
|
+
distribution.each do |bucket, weight|
|
|
31
|
+
cumulative += weight
|
|
32
|
+
return delay_for(bucket) if threshold < cumulative
|
|
33
|
+
end
|
|
34
|
+
delay_for(distribution.keys.last)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delay_for(bucket)
|
|
38
|
+
case bucket
|
|
39
|
+
when :same_day then 0
|
|
40
|
+
when :next_day then 1
|
|
41
|
+
when :two_to_three_days then @rng.rand(2..3)
|
|
42
|
+
when :next_week then 7
|
|
43
|
+
when :net_30 then 30
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/dogfood/step.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
class Step
|
|
5
|
+
attr_reader :number, :actor, :event, :action, :notes
|
|
6
|
+
|
|
7
|
+
def initialize(number:, actor:, event:, action:, notes: nil)
|
|
8
|
+
@number = number
|
|
9
|
+
@actor = actor
|
|
10
|
+
@event = event
|
|
11
|
+
@action = action
|
|
12
|
+
@notes = notes || ""
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_row
|
|
16
|
+
[number, actor, event, action, notes]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
class StoryBase
|
|
5
|
+
attr_reader :rng, :steps, :id
|
|
6
|
+
|
|
7
|
+
def initialize(rng:, id:)
|
|
8
|
+
@rng = rng
|
|
9
|
+
@id = id
|
|
10
|
+
@steps = []
|
|
11
|
+
@state = { status: :estimate, substatus: nil }
|
|
12
|
+
@step_number = 0
|
|
13
|
+
@next_stage = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def title
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def template_key
|
|
21
|
+
self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def state
|
|
25
|
+
@state.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def advance(day_index:)
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
protected
|
|
33
|
+
|
|
34
|
+
def step(actor:, event:, action:, notes: nil)
|
|
35
|
+
@step_number += 1
|
|
36
|
+
@steps << Dogfood::Step.new(
|
|
37
|
+
number: @step_number,
|
|
38
|
+
actor: actor,
|
|
39
|
+
event: event,
|
|
40
|
+
action: action,
|
|
41
|
+
notes: notes || ""
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update_state(status:, substatus:)
|
|
46
|
+
@state[:status] = status
|
|
47
|
+
@state[:substatus] = substatus
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def branch(weights)
|
|
51
|
+
@rng.branch(weights)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pick(collection)
|
|
55
|
+
@rng.pick(collection)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sample(collection, count)
|
|
59
|
+
@rng.sample(collection, count)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rand(range = nil)
|
|
63
|
+
@rng.rand(range)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
class StoryBuilder
|
|
5
|
+
def initialize(pack:, rng:)
|
|
6
|
+
@pack = pack
|
|
7
|
+
@rng = rng
|
|
8
|
+
@counter = 0
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build(fleet_ratio: 0.0, include: nil)
|
|
12
|
+
@counter += 1
|
|
13
|
+
id = "WO-#{@counter.to_s.rjust(3, '0')}"
|
|
14
|
+
name = include || @pack.scenario_keys.sample(random: @rng)
|
|
15
|
+
klass = @pack.find_scenario(name)
|
|
16
|
+
klass.new(rng: @rng, id: id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def available_keys
|
|
20
|
+
@pack.scenario_keys
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Shop Simulation
|
|
2
|
+
|
|
3
|
+
**Generated:** <%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
4
|
+
**Seed:** <%= @result[:seed] %>
|
|
5
|
+
**Days:** <%= @result[:days].length %>
|
|
6
|
+
|
|
7
|
+
<% @result[:days].each do |day| -%>
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Day <%= day[:index] + 1 %> — <%= day[:date] %>
|
|
11
|
+
|
|
12
|
+
### Shift briefing
|
|
13
|
+
- New work orders today: <%= day[:morning].length + day[:afternoon].length + day[:evening].length - day[:carry_overs].length %>
|
|
14
|
+
- Carry-overs: <%= day[:carry_overs].length %>
|
|
15
|
+
|
|
16
|
+
<% unless day[:carry_overs].empty? -%>
|
|
17
|
+
### Carry-overs
|
|
18
|
+
<% day[:carry_overs].each do |wo| -%>
|
|
19
|
+
- **<%= wo[:id] %>**: status `<%= wo[:status] %>` / `<%= wo[:substatus] %>`
|
|
20
|
+
<% end -%>
|
|
21
|
+
<% end -%>
|
|
22
|
+
|
|
23
|
+
### Morning
|
|
24
|
+
<% day[:morning].each do |story| -%>
|
|
25
|
+
<%= render_story(story) %>
|
|
26
|
+
<% end -%>
|
|
27
|
+
|
|
28
|
+
### Afternoon
|
|
29
|
+
<% day[:afternoon].each do |story| -%>
|
|
30
|
+
<%= render_story(story) %>
|
|
31
|
+
<% end -%>
|
|
32
|
+
|
|
33
|
+
### Evening
|
|
34
|
+
<% day[:evening].each do |story| -%>
|
|
35
|
+
<%= render_story(story) %>
|
|
36
|
+
<% end -%>
|
|
37
|
+
|
|
38
|
+
### End-of-day board
|
|
39
|
+
| WO | Status | Substatus | Next action |
|
|
40
|
+
|----|--------|-----------|-------------|
|
|
41
|
+
<% day[:end_of_day_board].each do |wo| -%>
|
|
42
|
+
| <%= escape_table_cell(wo[:id]) %> | <%= escape_table_cell(wo[:status]) %> | <%= escape_table_cell(wo[:substatus]) %> | <%= escape_table_cell(wo[:substatus] || "Continue") %> |
|
|
43
|
+
<% end -%>
|
|
44
|
+
<% end -%>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
module Test
|
|
5
|
+
# CI smoke-test helper. See SPEC.md 6.
|
|
6
|
+
module Smoke
|
|
7
|
+
def self.load_pack(path)
|
|
8
|
+
Dogfood::Pack.current = nil
|
|
9
|
+
load path # the pack file sets Dogfood::Pack.current
|
|
10
|
+
Dogfood::Pack.current or raise "Pack file did not set Dogfood::Pack.current"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.run_scenario(pack, name, days:, seed:)
|
|
14
|
+
pack.find_scenario(name)
|
|
15
|
+
generator = Dogfood::DayGenerator.new(pack: pack, days: days, seed: seed)
|
|
16
|
+
generator.generate # raises on any exception
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/dogfood.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dogfood/version"
|
|
4
|
+
require_relative "dogfood/randomness"
|
|
5
|
+
require_relative "dogfood/scheduler"
|
|
6
|
+
require_relative "dogfood/ledger"
|
|
7
|
+
require_relative "dogfood/step"
|
|
8
|
+
require_relative "dogfood/story_base"
|
|
9
|
+
require_relative "dogfood/story_builder"
|
|
10
|
+
require_relative "dogfood/pack"
|
|
11
|
+
require_relative "dogfood/day_generator"
|
|
12
|
+
require_relative "dogfood/renderer"
|
|
13
|
+
require_relative "dogfood/dsl/evaluator"
|
|
14
|
+
require_relative "dogfood/dsl/schema"
|
|
15
|
+
require_relative "dogfood/dsl/compiler"
|
|
16
|
+
require_relative "dogfood/dsl/explain_renderer"
|
|
17
|
+
require_relative "dogfood/test/smoke"
|
|
18
|
+
require_relative "dogfood/cli"
|
|
19
|
+
|
|
20
|
+
module Dogfood
|
|
21
|
+
NestingTooDeep = Class.new(StandardError)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if defined?(Rails::Engine)
|
|
25
|
+
require_relative "dogfood/railtie"
|
|
26
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Dogfood
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def create_pack_file
|
|
11
|
+
template "dogfood.rb.tt", "config/dogfood.rb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_scenarios_dir
|
|
15
|
+
empty_directory "config/dogfood"
|
|
16
|
+
template "example_scenario.yml.tt", "config/dogfood/example.yml"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_smoke_test
|
|
20
|
+
template "yml_smoke_test.rb.tt", "test/dogfood/yml_smoke_test.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show_readme
|
|
24
|
+
say "\nDogfood installed. Edit config/dogfood.rb to register your"
|
|
25
|
+
say "step modules, then drop scenarios into config/dogfood/*.yml."
|
|
26
|
+
say "Run `bin/rails dogfood --list` to see them.\n"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dogfood
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ben D'Angelo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: railties
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '6.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '6.0'
|
|
54
|
+
description: Generates realistic multi-day workflows for UX testing. Domain-agnostic;
|
|
55
|
+
content supplied via packs.
|
|
56
|
+
email:
|
|
57
|
+
- ben@bendangelo.me
|
|
58
|
+
executables:
|
|
59
|
+
- dogfood
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- bin/dogfood
|
|
64
|
+
- lib/dogfood.rb
|
|
65
|
+
- lib/dogfood/cli.rb
|
|
66
|
+
- lib/dogfood/day_generator.rb
|
|
67
|
+
- lib/dogfood/dsl/compiler.rb
|
|
68
|
+
- lib/dogfood/dsl/evaluator.rb
|
|
69
|
+
- lib/dogfood/dsl/explain_renderer.rb
|
|
70
|
+
- lib/dogfood/dsl/schema.rb
|
|
71
|
+
- lib/dogfood/ledger.rb
|
|
72
|
+
- lib/dogfood/pack.rb
|
|
73
|
+
- lib/dogfood/railtie.rb
|
|
74
|
+
- lib/dogfood/randomness.rb
|
|
75
|
+
- lib/dogfood/renderer.rb
|
|
76
|
+
- lib/dogfood/scheduler.rb
|
|
77
|
+
- lib/dogfood/step.rb
|
|
78
|
+
- lib/dogfood/story_base.rb
|
|
79
|
+
- lib/dogfood/story_builder.rb
|
|
80
|
+
- lib/dogfood/templates/day_log.md.erb
|
|
81
|
+
- lib/dogfood/test/smoke.rb
|
|
82
|
+
- lib/dogfood/version.rb
|
|
83
|
+
- lib/generators/dogfood/install/install_generator.rb
|
|
84
|
+
homepage: https://github.com/bendangelo/dogfood
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
homepage_uri: https://github.com/bendangelo/dogfood
|
|
89
|
+
source_code_uri: https://github.com/bendangelo/dogfood
|
|
90
|
+
bug_tracker_uri: https://github.com/bendangelo/dogfood/issues
|
|
91
|
+
changelog_uri: https://github.com/bendangelo/dogfood/blob/master/CHANGELOG.md
|
|
92
|
+
rubygems_mfa_required: 'true'
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.1'
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 4.0.15
|
|
108
|
+
specification_version: 4
|
|
109
|
+
summary: Generic multi-day scenario simulation engine
|
|
110
|
+
test_files: []
|