flowengine 0.1.0 → 0.1.2
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 +4 -4
- data/.rubocop_todo.yml +7 -2
- data/README.md +828 -81
- data/Rakefile +11 -11
- data/docs/floweingine-architecture.png +0 -0
- data/justfile +9 -0
- data/lib/flowengine/definition.rb +13 -0
- data/lib/flowengine/dsl/flow_builder.rb +13 -0
- data/lib/flowengine/dsl/rule_helpers.rb +22 -0
- data/lib/flowengine/dsl/step_builder.rb +30 -1
- data/lib/flowengine/dsl.rb +2 -0
- data/lib/flowengine/engine.rb +82 -0
- data/lib/flowengine/errors.rb +11 -0
- data/lib/flowengine/evaluator.rb +9 -0
- data/lib/flowengine/graph/mermaid_exporter.rb +7 -0
- data/lib/flowengine/node.rb +35 -1
- data/lib/flowengine/rules/all.rb +7 -0
- data/lib/flowengine/rules/any.rb +7 -0
- data/lib/flowengine/rules/base.rb +9 -0
- data/lib/flowengine/rules/contains.rb +10 -0
- data/lib/flowengine/rules/equals.rb +9 -0
- data/lib/flowengine/rules/greater_than.rb +9 -0
- data/lib/flowengine/rules/less_than.rb +9 -0
- data/lib/flowengine/rules/not_empty.rb +7 -0
- data/lib/flowengine/transition.rb +14 -0
- data/lib/flowengine/validation/adapter.rb +13 -0
- data/lib/flowengine/validation/null_adapter.rb +4 -0
- data/lib/flowengine/version.rb +2 -1
- data/lib/flowengine.rb +43 -0
- metadata +2 -1
data/Rakefile
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "yard"
|
|
7
7
|
|
|
8
8
|
def shell(*args)
|
|
9
|
-
puts "running: #{args.join(
|
|
10
|
-
system(args.join(
|
|
9
|
+
puts "running: #{args.join(" ")}"
|
|
10
|
+
system(args.join(" "))
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
task :clean do
|
|
14
|
-
shell(
|
|
14
|
+
shell("rm -rf pkg/ tmp/ coverage/ doc/ ")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
task gem: [:build] do
|
|
18
|
-
shell(
|
|
18
|
+
shell("gem install pkg/*")
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
task permissions: [:clean] do
|
|
@@ -26,9 +26,9 @@ end
|
|
|
26
26
|
task build: :permissions
|
|
27
27
|
|
|
28
28
|
YARD::Rake::YardocTask.new(:doc) do |t|
|
|
29
|
-
t.files = %w
|
|
30
|
-
t.options.unshift(
|
|
31
|
-
t.after = -> { exec(
|
|
29
|
+
t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt CHANGELOG.md]
|
|
30
|
+
t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
|
|
31
|
+
t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
RSpec::Core::RakeTask.new(:spec)
|
|
Binary file
|
data/justfile
CHANGED
|
@@ -2,6 +2,10 @@ set shell := ["bash", "-c"]
|
|
|
2
2
|
|
|
3
3
|
set dotenv-load
|
|
4
4
|
|
|
5
|
+
[no-exit-message]
|
|
6
|
+
recipes:
|
|
7
|
+
@just --choose
|
|
8
|
+
|
|
5
9
|
test:
|
|
6
10
|
@bundle exec rspec
|
|
7
11
|
@bundle exec rubocop
|
|
@@ -32,6 +36,11 @@ format:
|
|
|
32
36
|
lint:
|
|
33
37
|
@bundle exec rubocop
|
|
34
38
|
|
|
39
|
+
# Generates library documentation into ./doc folder and opens the browser
|
|
40
|
+
doc:
|
|
41
|
+
@bundle exec rake doc
|
|
42
|
+
@open ./doc/index.html
|
|
43
|
+
|
|
35
44
|
|
|
36
45
|
check-all: lint test
|
|
37
46
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Immutable, versionable flow graph: maps step ids to {Node} objects and defines the entry point.
|
|
5
|
+
# Built by {DSL::FlowBuilder}; consumed by {Engine} for navigation.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader start_step_id [Symbol] id of the first step in the flow
|
|
8
|
+
# @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node (read-only)
|
|
4
9
|
class Definition
|
|
5
10
|
attr_reader :start_step_id, :steps
|
|
6
11
|
|
|
12
|
+
# @param start_step_id [Symbol] id of the initial step
|
|
13
|
+
# @param nodes [Hash<Symbol, Node>] all steps keyed by id
|
|
14
|
+
# @raise [DefinitionError] if start_step_id is not present in nodes
|
|
7
15
|
def initialize(start_step_id:, nodes:)
|
|
8
16
|
@start_step_id = start_step_id
|
|
9
17
|
@steps = nodes.freeze
|
|
@@ -11,14 +19,19 @@ module FlowEngine
|
|
|
11
19
|
freeze
|
|
12
20
|
end
|
|
13
21
|
|
|
22
|
+
# @return [Node] the node for the start step
|
|
14
23
|
def start_step
|
|
15
24
|
step(start_step_id)
|
|
16
25
|
end
|
|
17
26
|
|
|
27
|
+
# @param id [Symbol] step id
|
|
28
|
+
# @return [Node] the node for that step
|
|
29
|
+
# @raise [UnknownStepError] if id is not in steps
|
|
18
30
|
def step(id)
|
|
19
31
|
steps.fetch(id) { raise UnknownStepError, "Unknown step: #{id.inspect}" }
|
|
20
32
|
end
|
|
21
33
|
|
|
34
|
+
# @return [Array<Symbol>] all step ids in the definition
|
|
22
35
|
def step_ids
|
|
23
36
|
steps.keys
|
|
24
37
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Builds a {Definition} from the declarative DSL used in {FlowEngine.define}.
|
|
6
|
+
# Provides {#start} and {#step}; each step block is evaluated by {StepBuilder} with {RuleHelpers}.
|
|
5
7
|
class FlowBuilder
|
|
6
8
|
include RuleHelpers
|
|
7
9
|
|
|
@@ -10,16 +12,27 @@ module FlowEngine
|
|
|
10
12
|
@nodes = {}
|
|
11
13
|
end
|
|
12
14
|
|
|
15
|
+
# Sets the entry step id for the flow.
|
|
16
|
+
#
|
|
17
|
+
# @param step_id [Symbol] id of the first step
|
|
13
18
|
def start(step_id)
|
|
14
19
|
@start_step_id = step_id
|
|
15
20
|
end
|
|
16
21
|
|
|
22
|
+
# Defines one step by id; the block is evaluated in a {StepBuilder} context.
|
|
23
|
+
#
|
|
24
|
+
# @param id [Symbol] step id
|
|
25
|
+
# @yield block evaluated in {StepBuilder} (type, question, options, transition, etc.)
|
|
17
26
|
def step(id, &)
|
|
18
27
|
builder = StepBuilder.new
|
|
19
28
|
builder.instance_eval(&)
|
|
20
29
|
@nodes[id] = builder.build(id)
|
|
21
30
|
end
|
|
22
31
|
|
|
32
|
+
# Produces the frozen {Definition} from the accumulated start and steps.
|
|
33
|
+
#
|
|
34
|
+
# @return [Definition]
|
|
35
|
+
# @raise [DefinitionError] if start was not set or no steps were defined
|
|
23
36
|
def build
|
|
24
37
|
raise DefinitionError, "No start step defined" if @start_step_id.nil?
|
|
25
38
|
raise DefinitionError, "No steps defined" if @nodes.empty?
|
|
@@ -2,31 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Factory methods for rule objects, included in {FlowBuilder} and {StepBuilder}.
|
|
6
|
+
# Use these inside step blocks for transition conditions and visible_if.
|
|
5
7
|
module RuleHelpers
|
|
8
|
+
# @param field [Symbol] answer key (step id)
|
|
9
|
+
# @param value [Object] value to check for in the array
|
|
10
|
+
# @return [Rules::Contains]
|
|
6
11
|
def contains(field, value)
|
|
7
12
|
Rules::Contains.new(field, value)
|
|
8
13
|
end
|
|
9
14
|
|
|
15
|
+
# @param field [Symbol] answer key
|
|
16
|
+
# @param value [Object] expected value
|
|
17
|
+
# @return [Rules::Equals]
|
|
10
18
|
def equals(field, value)
|
|
11
19
|
Rules::Equals.new(field, value)
|
|
12
20
|
end
|
|
13
21
|
|
|
22
|
+
# @param field [Symbol] answer key (value coerced to integer for comparison)
|
|
23
|
+
# @param value [Integer] threshold
|
|
24
|
+
# @return [Rules::GreaterThan]
|
|
14
25
|
def greater_than(field, value)
|
|
15
26
|
Rules::GreaterThan.new(field, value)
|
|
16
27
|
end
|
|
17
28
|
|
|
29
|
+
# @param field [Symbol] answer key (value coerced to integer for comparison)
|
|
30
|
+
# @param value [Integer] threshold
|
|
31
|
+
# @return [Rules::LessThan]
|
|
18
32
|
def less_than(field, value)
|
|
19
33
|
Rules::LessThan.new(field, value)
|
|
20
34
|
end
|
|
21
35
|
|
|
36
|
+
# @param field [Symbol] answer key
|
|
37
|
+
# @return [Rules::NotEmpty]
|
|
22
38
|
def not_empty(field)
|
|
23
39
|
Rules::NotEmpty.new(field)
|
|
24
40
|
end
|
|
25
41
|
|
|
42
|
+
# Logical AND of multiple rules.
|
|
43
|
+
# @param rules [Array<Rules::Base>] rules to combine
|
|
44
|
+
# @return [Rules::All]
|
|
26
45
|
def all(*rules)
|
|
27
46
|
Rules::All.new(*rules)
|
|
28
47
|
end
|
|
29
48
|
|
|
49
|
+
# Logical OR of multiple rules.
|
|
50
|
+
# @param rules [Array<Rules::Base>] rules to combine
|
|
51
|
+
# @return [Rules::Any]
|
|
30
52
|
def any(*rules)
|
|
31
53
|
Rules::Any.new(*rules)
|
|
32
54
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Builds a single {Node} from step DSL (type, question, options, transitions, visibility).
|
|
6
|
+
# Used by {FlowBuilder#step}; includes {RuleHelpers} for transition/visibility conditions.
|
|
5
7
|
class StepBuilder
|
|
6
8
|
include RuleHelpers
|
|
7
9
|
|
|
@@ -12,32 +14,58 @@ module FlowEngine
|
|
|
12
14
|
@fields = nil
|
|
13
15
|
@transitions = []
|
|
14
16
|
@visibility_rule = nil
|
|
17
|
+
@decorations = nil
|
|
15
18
|
end
|
|
16
19
|
|
|
20
|
+
# Sets the step/input type (e.g. :multi_select, :number_matrix).
|
|
21
|
+
# @param value [Symbol]
|
|
17
22
|
def type(value)
|
|
18
23
|
@type = value
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
# Sets the prompt/label for the step.
|
|
27
|
+
# @param text [String]
|
|
21
28
|
def question(text)
|
|
22
29
|
@question = text
|
|
23
30
|
end
|
|
24
31
|
|
|
32
|
+
# Sets the list of options for multi-select or choice steps.
|
|
33
|
+
# @param list [Array]
|
|
25
34
|
def options(list)
|
|
26
35
|
@options = list
|
|
27
36
|
end
|
|
28
37
|
|
|
38
|
+
# Sets the list of field names for matrix-style steps (e.g. number_matrix).
|
|
39
|
+
# @param list [Array]
|
|
29
40
|
def fields(list)
|
|
30
41
|
@fields = list
|
|
31
42
|
end
|
|
32
43
|
|
|
44
|
+
# Optional UI decorations (opaque to the engine).
|
|
45
|
+
# @param decorations [Object]
|
|
46
|
+
def decorations(decorations)
|
|
47
|
+
@decorations = decorations
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Adds a conditional transition to another step. First matching transition wins.
|
|
51
|
+
#
|
|
52
|
+
# @param to [Symbol] target step id
|
|
53
|
+
# @param if_rule [Rules::Base, nil] condition (nil = unconditional)
|
|
33
54
|
def transition(to:, if_rule: nil)
|
|
34
55
|
@transitions << Transition.new(target: to, rule: if_rule)
|
|
35
56
|
end
|
|
36
57
|
|
|
58
|
+
# Sets the visibility rule for this step (DAG mode: step shown only when rule is true).
|
|
59
|
+
#
|
|
60
|
+
# @param rule [Rules::Base]
|
|
37
61
|
def visible_if(rule)
|
|
38
62
|
@visibility_rule = rule
|
|
39
63
|
end
|
|
40
64
|
|
|
65
|
+
# Builds the {Node} for the given step id from accumulated attributes.
|
|
66
|
+
#
|
|
67
|
+
# @param id [Symbol] step id
|
|
68
|
+
# @return [Node]
|
|
41
69
|
def build(id)
|
|
42
70
|
Node.new(
|
|
43
71
|
id: id,
|
|
@@ -46,7 +74,8 @@ module FlowEngine
|
|
|
46
74
|
options: @options,
|
|
47
75
|
fields: @fields,
|
|
48
76
|
transitions: @transitions,
|
|
49
|
-
visibility_rule: @visibility_rule
|
|
77
|
+
visibility_rule: @visibility_rule,
|
|
78
|
+
decorations: @decorations
|
|
50
79
|
)
|
|
51
80
|
end
|
|
52
81
|
end
|
data/lib/flowengine/dsl.rb
CHANGED
|
@@ -5,6 +5,8 @@ require_relative "dsl/step_builder"
|
|
|
5
5
|
require_relative "dsl/flow_builder"
|
|
6
6
|
|
|
7
7
|
module FlowEngine
|
|
8
|
+
# Namespace for the declarative flow DSL: {FlowBuilder} builds a {Definition} from blocks,
|
|
9
|
+
# {StepBuilder} builds individual {Node}s, and {RuleHelpers} provide rule factory methods.
|
|
8
10
|
module DSL
|
|
9
11
|
end
|
|
10
12
|
end
|
data/lib/flowengine/engine.rb
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Runtime session that drives flow navigation: holds definition, answers, and current step.
|
|
5
|
+
# Validates each answer via an optional {Validation::Adapter}, then advances using node transitions.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader definition [Definition] immutable flow definition
|
|
8
|
+
# @attr_reader answers [Hash] step_id => value (mutable as user answers)
|
|
9
|
+
# @attr_reader history [Array<Symbol>] ordered list of step ids visited (including current)
|
|
10
|
+
# @attr_reader current_step_id [Symbol, nil] current step id, or nil when flow is finished
|
|
4
11
|
class Engine
|
|
5
12
|
attr_reader :definition, :answers, :history, :current_step_id
|
|
6
13
|
|
|
14
|
+
# @param definition [Definition] the flow to run
|
|
15
|
+
# @param validator [Validation::Adapter] validator for step answers (default: {Validation::NullAdapter})
|
|
7
16
|
def initialize(definition, validator: Validation::NullAdapter.new)
|
|
8
17
|
@definition = definition
|
|
9
18
|
@answers = {}
|
|
@@ -13,16 +22,23 @@ module FlowEngine
|
|
|
13
22
|
@history << @current_step_id
|
|
14
23
|
end
|
|
15
24
|
|
|
25
|
+
# @return [Node, nil] current step node, or nil if flow is finished
|
|
16
26
|
def current_step
|
|
17
27
|
return nil if finished?
|
|
18
28
|
|
|
19
29
|
definition.step(@current_step_id)
|
|
20
30
|
end
|
|
21
31
|
|
|
32
|
+
# @return [Boolean] true when there is no current step (flow ended)
|
|
22
33
|
def finished?
|
|
23
34
|
@current_step_id.nil?
|
|
24
35
|
end
|
|
25
36
|
|
|
37
|
+
# Submits an answer for the current step, validates it, stores it, and advances to the next step.
|
|
38
|
+
#
|
|
39
|
+
# @param value [Object] user's answer for the current step
|
|
40
|
+
# @raise [AlreadyFinishedError] if the flow has already finished
|
|
41
|
+
# @raise [ValidationError] if the validator rejects the value
|
|
26
42
|
def answer(value)
|
|
27
43
|
raise AlreadyFinishedError, "Flow is already finished" if finished?
|
|
28
44
|
|
|
@@ -33,8 +49,74 @@ module FlowEngine
|
|
|
33
49
|
advance_step
|
|
34
50
|
end
|
|
35
51
|
|
|
52
|
+
# Serializable state for persistence or resumption.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash] current_step_id, answers, and history (string/symbol keys as stored)
|
|
55
|
+
def to_state
|
|
56
|
+
{
|
|
57
|
+
current_step_id: @current_step_id,
|
|
58
|
+
answers: @answers,
|
|
59
|
+
history: @history
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Rebuilds an engine from a previously saved state (e.g. from DB or session).
|
|
64
|
+
#
|
|
65
|
+
# @param definition [Definition] same definition used when state was captured
|
|
66
|
+
# @param state_hash [Hash] hash with :current_step_id, :answers, :history (keys may be strings)
|
|
67
|
+
# @param validator [Validation::Adapter] validator to use (default: NullAdapter)
|
|
68
|
+
# @return [Engine] restored engine instance
|
|
69
|
+
def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
|
|
70
|
+
state = symbolize_state(state_hash)
|
|
71
|
+
engine = allocate
|
|
72
|
+
engine.send(:restore_state, definition, state, validator)
|
|
73
|
+
engine
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Normalizes a state hash so step ids and history entries are symbols; answers keys are symbols.
|
|
77
|
+
#
|
|
78
|
+
# @param hash [Hash] raw state (e.g. from JSON)
|
|
79
|
+
# @return [Hash] symbolized state
|
|
80
|
+
def self.symbolize_state(hash)
|
|
81
|
+
return hash unless hash.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
84
|
+
sym_key = key.to_sym
|
|
85
|
+
result[sym_key] = case sym_key
|
|
86
|
+
when :current_step_id
|
|
87
|
+
value&.to_sym
|
|
88
|
+
when :history
|
|
89
|
+
Array(value).map { |v| v&.to_sym }
|
|
90
|
+
when :answers
|
|
91
|
+
symbolize_answers(value)
|
|
92
|
+
else
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param answers [Hash] answers map (keys may be strings)
|
|
99
|
+
# @return [Hash] same map with symbol keys
|
|
100
|
+
def self.symbolize_answers(answers)
|
|
101
|
+
return {} unless answers.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
answers.each_with_object({}) do |(key, value), result|
|
|
104
|
+
result[key.to_sym] = value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private_class_method :symbolize_state, :symbolize_answers
|
|
109
|
+
|
|
36
110
|
private
|
|
37
111
|
|
|
112
|
+
def restore_state(definition, state, validator)
|
|
113
|
+
@definition = definition
|
|
114
|
+
@validator = validator
|
|
115
|
+
@current_step_id = state[:current_step_id]
|
|
116
|
+
@answers = state[:answers] || {}
|
|
117
|
+
@history = state[:history] || []
|
|
118
|
+
end
|
|
119
|
+
|
|
38
120
|
def advance_step
|
|
39
121
|
node = definition.step(@current_step_id)
|
|
40
122
|
next_id = node.next_step_id(answers)
|
data/lib/flowengine/errors.rb
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Base exception for all flowengine errors.
|
|
4
5
|
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a flow definition is invalid (e.g. missing start step, unknown step reference).
|
|
5
8
|
class DefinitionError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when navigating to or requesting a step id that does not exist in the definition.
|
|
6
11
|
class UnknownStepError < Error; end
|
|
12
|
+
|
|
13
|
+
# Base exception for runtime engine errors (e.g. validation, already finished).
|
|
7
14
|
class EngineError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when {Engine#answer} is called after the flow has already finished.
|
|
8
17
|
class AlreadyFinishedError < EngineError; end
|
|
18
|
+
|
|
19
|
+
# Raised when the validator rejects the user's answer for the current step.
|
|
9
20
|
class ValidationError < EngineError; end
|
|
10
21
|
end
|
data/lib/flowengine/evaluator.rb
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Evaluates rule AST nodes against a given answer context.
|
|
5
|
+
# Used by transitions and visibility checks; rule objects implement {Rules::Base#evaluate}.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader answers [Hash] current answer state (step_id => value)
|
|
4
8
|
class Evaluator
|
|
5
9
|
attr_reader :answers
|
|
6
10
|
|
|
11
|
+
# @param answers [Hash] answer context to evaluate rules against
|
|
7
12
|
def initialize(answers)
|
|
8
13
|
@answers = answers
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
# Evaluates a single rule (or returns true if rule is nil).
|
|
17
|
+
#
|
|
18
|
+
# @param rule [Rules::Base, nil] rule to evaluate
|
|
19
|
+
# @return [Boolean] result of rule#evaluate(answers), or true when rule is nil
|
|
11
20
|
def evaluate(rule)
|
|
12
21
|
return true if rule.nil?
|
|
13
22
|
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Graph
|
|
5
|
+
# Exports a flow {Definition} to Mermaid flowchart syntax for visualization.
|
|
6
|
+
# Nodes are labeled with truncated question text; edges show condition labels when present.
|
|
5
7
|
class MermaidExporter
|
|
8
|
+
# Maximum characters for node labels in the diagram (longer text is truncated with "...").
|
|
6
9
|
MAX_LABEL_LENGTH = 50
|
|
7
10
|
|
|
8
11
|
attr_reader :definition
|
|
9
12
|
|
|
13
|
+
# @param definition [Definition] flow to export
|
|
10
14
|
def initialize(definition)
|
|
11
15
|
@definition = definition
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# Generates Mermaid flowchart TD (top-down) source.
|
|
19
|
+
#
|
|
20
|
+
# @return [String] Mermaid diagram source (e.g. for Mermaid.js or docs)
|
|
14
21
|
def export
|
|
15
22
|
lines = ["flowchart TD"]
|
|
16
23
|
|
data/lib/flowengine/node.rb
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# A single step in the flow: question metadata, input config, and conditional transitions.
|
|
5
|
+
# Used by {Engine} to determine the next step and by UI/export to render the step.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader id [Symbol] unique step identifier
|
|
8
|
+
# @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix)
|
|
9
|
+
# @attr_reader question [String] prompt text for the step
|
|
10
|
+
# @attr_reader options [Array, nil] choices for multi_select; nil for other types
|
|
11
|
+
# @attr_reader fields [Array, nil] field names for number_matrix etc.; nil otherwise
|
|
12
|
+
# @attr_reader transitions [Array<Transition>] ordered list of conditional next-step rules
|
|
13
|
+
# @attr_reader visibility_rule [Rules::Base, nil] rule controlling whether this node is visible (DAG mode)
|
|
4
14
|
class Node
|
|
5
15
|
attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
|
|
6
16
|
|
|
7
|
-
|
|
17
|
+
# @param id [Symbol] step id
|
|
18
|
+
# @param type [Symbol] step/input type
|
|
19
|
+
# @param question [String] label/prompt
|
|
20
|
+
# @param decorations [Object, nil] optional UI decorations (not used by engine)
|
|
21
|
+
# @param options [Array, nil] option list for multi_select
|
|
22
|
+
# @param fields [Array, nil] field list for matrix-style steps
|
|
23
|
+
# @param transitions [Array<Transition>] conditional next-step transitions (default: [])
|
|
24
|
+
# @param visibility_rule [Rules::Base, nil] optional rule for visibility (default: always visible)
|
|
25
|
+
def initialize(id:, # rubocop:disable Metrics/ParameterLists
|
|
26
|
+
type:,
|
|
27
|
+
question:,
|
|
28
|
+
decorations: nil,
|
|
29
|
+
options: nil,
|
|
30
|
+
fields: nil,
|
|
31
|
+
transitions: [],
|
|
32
|
+
visibility_rule: nil)
|
|
8
33
|
@id = id
|
|
9
34
|
@type = type
|
|
10
35
|
@question = question
|
|
36
|
+
@decorations = decorations
|
|
11
37
|
@options = options&.freeze
|
|
12
38
|
@fields = fields&.freeze
|
|
13
39
|
@transitions = transitions.freeze
|
|
@@ -15,11 +41,19 @@ module FlowEngine
|
|
|
15
41
|
freeze
|
|
16
42
|
end
|
|
17
43
|
|
|
44
|
+
# Resolves the next step id from current answers by evaluating transitions in order.
|
|
45
|
+
#
|
|
46
|
+
# @param answers [Hash] current answer state (step_id => value)
|
|
47
|
+
# @return [Symbol, nil] id of the next step, or nil if no transition matches (flow end)
|
|
18
48
|
def next_step_id(answers)
|
|
19
49
|
match = transitions.find { |t| t.applies?(answers) }
|
|
20
50
|
match&.target
|
|
21
51
|
end
|
|
22
52
|
|
|
53
|
+
# Whether this node should be considered visible given current answers (for DAG/visibility).
|
|
54
|
+
#
|
|
55
|
+
# @param answers [Hash] current answer state
|
|
56
|
+
# @return [Boolean] true if no visibility_rule, else result of rule evaluation
|
|
23
57
|
def visible?(answers)
|
|
24
58
|
return true if visibility_rule.nil?
|
|
25
59
|
|
data/lib/flowengine/rules/all.rb
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Composite rule: logical AND of multiple sub-rules. All must be true.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader rules [Array<Base>] sub-rules to evaluate
|
|
5
8
|
class All < Base
|
|
6
9
|
attr_reader :rules
|
|
7
10
|
|
|
11
|
+
# @param rules [Array<Base>] one or more rules (arrays are flattened)
|
|
8
12
|
def initialize(*rules)
|
|
9
13
|
super()
|
|
10
14
|
@rules = rules.flatten.freeze
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] true if every sub-rule evaluates to true
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
rules.all? { |rule| rule.evaluate(answers) }
|
|
16
22
|
end
|
|
17
23
|
|
|
24
|
+
# @return [String] e.g. "(rule1 AND rule2 AND rule3)"
|
|
18
25
|
def to_s
|
|
19
26
|
"(#{rules.join(" AND ")})"
|
|
20
27
|
end
|
data/lib/flowengine/rules/any.rb
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Composite rule: logical OR of multiple sub-rules. At least one must be true.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader rules [Array<Base>] sub-rules to evaluate
|
|
5
8
|
class Any < Base
|
|
6
9
|
attr_reader :rules
|
|
7
10
|
|
|
11
|
+
# @param rules [Array<Base>] one or more rules (arrays are flattened)
|
|
8
12
|
def initialize(*rules)
|
|
9
13
|
super()
|
|
10
14
|
@rules = rules.flatten.freeze
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] true if any sub-rule evaluates to true
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
rules.any? { |rule| rule.evaluate(answers) }
|
|
16
22
|
end
|
|
17
23
|
|
|
24
|
+
# @return [String] e.g. "(rule1 OR rule2 OR rule3)"
|
|
18
25
|
def to_s
|
|
19
26
|
"(#{rules.join(" OR ")})"
|
|
20
27
|
end
|
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Abstract base for rule AST nodes. Subclasses implement {#evaluate} and {#to_s}
|
|
6
|
+
# for use in transitions and visibility conditions.
|
|
5
7
|
class Base
|
|
8
|
+
# Evaluates the rule against the current answer context.
|
|
9
|
+
#
|
|
10
|
+
# @param _answers [Hash] step_id => value
|
|
11
|
+
# @return [Boolean]
|
|
6
12
|
def evaluate(_answers)
|
|
7
13
|
raise NotImplementedError, "#{self.class}#evaluate must be implemented"
|
|
8
14
|
end
|
|
9
15
|
|
|
16
|
+
# Human-readable representation (e.g. for graph labels).
|
|
17
|
+
#
|
|
18
|
+
# @return [String]
|
|
10
19
|
def to_s
|
|
11
20
|
raise NotImplementedError, "#{self.class}#to_s must be implemented"
|
|
12
21
|
end
|
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field (as an array) includes the given value.
|
|
6
|
+
# Used for multi-select steps (e.g. "BusinessOwnership in earnings").
|
|
7
|
+
#
|
|
8
|
+
# @attr_reader field [Symbol] answer key (step id)
|
|
9
|
+
# @attr_reader value [Object] value that must be present in the array
|
|
5
10
|
class Contains < Base
|
|
6
11
|
attr_reader :field, :value
|
|
7
12
|
|
|
13
|
+
# @param field [Symbol] answer key
|
|
14
|
+
# @param value [Object] value to check for
|
|
8
15
|
def initialize(field, value)
|
|
9
16
|
super()
|
|
10
17
|
@field = field
|
|
@@ -12,10 +19,13 @@ module FlowEngine
|
|
|
12
19
|
freeze
|
|
13
20
|
end
|
|
14
21
|
|
|
22
|
+
# @param answers [Hash] current answers
|
|
23
|
+
# @return [Boolean] true if answers[field] (as array) includes value
|
|
15
24
|
def evaluate(answers)
|
|
16
25
|
Array(answers[field]).include?(value)
|
|
17
26
|
end
|
|
18
27
|
|
|
28
|
+
# @return [String] e.g. "BusinessOwnership in earnings"
|
|
19
29
|
def to_s
|
|
20
30
|
"#{value} in #{field}"
|
|
21
31
|
end
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field equals the given value.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
8
|
+
# @attr_reader value [Object] expected value
|
|
5
9
|
class Equals < Base
|
|
6
10
|
attr_reader :field, :value
|
|
7
11
|
|
|
12
|
+
# @param field [Symbol] answer key
|
|
13
|
+
# @param value [Object] expected value
|
|
8
14
|
def initialize(field, value)
|
|
9
15
|
super()
|
|
10
16
|
@field = field
|
|
@@ -12,10 +18,13 @@ module FlowEngine
|
|
|
12
18
|
freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# @param answers [Hash] current answers
|
|
22
|
+
# @return [Boolean] true if answers[field] == value
|
|
15
23
|
def evaluate(answers)
|
|
16
24
|
answers[field] == value
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# @return [String] e.g. "marital_status == Married"
|
|
19
28
|
def to_s
|
|
20
29
|
"#{field} == #{value}"
|
|
21
30
|
end
|