Merge pull request #7424 from betagouv/condition_engine
Condition engine
This commit is contained in:
commit
b4ab3487de
33 changed files with 808 additions and 14 deletions
|
@ -516,9 +516,6 @@ Lint/UselessAssignment:
|
|||
Lint/BinaryOperatorWithIdenticalOperands:
|
||||
Enabled: true
|
||||
|
||||
Lint/UselessElseWithoutRescue:
|
||||
Enabled: true
|
||||
|
||||
Lint/UselessSetterCall:
|
||||
Enabled: true
|
||||
|
||||
|
|
16
Gemfile.lock
16
Gemfile.lock
|
@ -464,7 +464,7 @@ GEM
|
|||
validate_url
|
||||
webfinger (>= 1.0.1)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.20.1)
|
||||
parallel (1.22.1)
|
||||
parser (3.1.2.0)
|
||||
ast (~> 2.4.1)
|
||||
pdf-core (0.9.0)
|
||||
|
@ -564,7 +564,7 @@ GEM
|
|||
rb-fsevent (0.10.4)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
regexp_parser (2.1.0)
|
||||
regexp_parser (2.5.0)
|
||||
request_store (1.5.0)
|
||||
rack (>= 1.4)
|
||||
responders (3.0.1)
|
||||
|
@ -608,17 +608,17 @@ GEM
|
|||
rspec-support (3.10.2)
|
||||
rspec_junit_formatter (0.4.1)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.10.0)
|
||||
rubocop (1.30.1)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.0.0.0)
|
||||
parser (>= 3.1.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml
|
||||
rubocop-ast (>= 1.2.0, < 2.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.18.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.4.1)
|
||||
parser (>= 2.7.1.5)
|
||||
rubocop-ast (1.18.0)
|
||||
parser (>= 3.1.1.0)
|
||||
rubocop-performance (1.9.2)
|
||||
rubocop (>= 0.90.0, < 2.0)
|
||||
rubocop-ast (>= 0.4.0)
|
||||
|
|
84
app/models/logic.rb
Normal file
84
app/models/logic.rb
Normal file
|
@ -0,0 +1,84 @@
|
|||
module Logic
|
||||
def self.from_h(h)
|
||||
class_from_name(h['op']).from_h(h)
|
||||
end
|
||||
|
||||
def self.from_json(s)
|
||||
from_h(JSON.parse(s))
|
||||
end
|
||||
|
||||
def self.class_from_name(name)
|
||||
[ChampValue, Constant, Empty, LessThan, LessThanEq, Eq, GreaterThanEq, GreaterThan, EmptyOperator, And, Or]
|
||||
.find { |c| c.name == name }
|
||||
end
|
||||
|
||||
def self.ensure_compatibility_from_left(condition)
|
||||
left = condition.left
|
||||
right = condition.right
|
||||
operator_class = condition.class
|
||||
|
||||
case [left.type, condition]
|
||||
in [:boolean, _]
|
||||
operator_class = Eq
|
||||
in [:empty, _]
|
||||
operator_class = EmptyOperator
|
||||
in [:enum, _]
|
||||
operator_class = Eq
|
||||
in [:number, EmptyOperator]
|
||||
operator_class = Eq
|
||||
in [:number, _]
|
||||
in [:string, _]
|
||||
operator_class = Eq
|
||||
end
|
||||
|
||||
if !compatible_type?(left, right)
|
||||
right = case left.type
|
||||
when :boolean
|
||||
Constant.new(true)
|
||||
when :empty
|
||||
Empty.new
|
||||
when :enum
|
||||
Constant.new(left.options.first)
|
||||
when :number
|
||||
Constant.new(0)
|
||||
when :string
|
||||
Constant.new('')
|
||||
end
|
||||
end
|
||||
|
||||
operator_class.new(left, right)
|
||||
end
|
||||
|
||||
def self.compatible_type?(left, right)
|
||||
case [left.type, right.type]
|
||||
in [a, ^a] # syntax for same type
|
||||
true
|
||||
in [:enum, :string]
|
||||
left.options.include?(right.value)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def ds_eq(left, right) = Logic::Eq.new(left, right)
|
||||
|
||||
def greater_than(left, right) = Logic::GreaterThan.new(left, right)
|
||||
|
||||
def greater_than_eq(left, right) = Logic::GreaterThanEq.new(left, right)
|
||||
|
||||
def less_than(left, right) = Logic::LessThan.new(left, right)
|
||||
|
||||
def less_than_eq(left, right) = Logic::LessThanEq.new(left, right)
|
||||
|
||||
def constant(value) = Logic::Constant.new(value)
|
||||
|
||||
def champ_value(stable_id) = Logic::ChampValue.new(stable_id)
|
||||
|
||||
def empty = Logic::Empty.new
|
||||
|
||||
def empty_operator(left, right) = Logic::EmptyOperator.new(left, right)
|
||||
|
||||
def ds_or(operands) = Logic::Or.new(operands)
|
||||
|
||||
def ds_and(operands) = Logic::And.new(operands)
|
||||
end
|
11
app/models/logic/and.rb
Normal file
11
app/models/logic/and.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class Logic::And < Logic::NAryOperator
|
||||
attr_reader :operands
|
||||
|
||||
def operator_name = 'Et'
|
||||
|
||||
def compute(champs = [])
|
||||
@operands.map { |operand| operand.compute(champs) }.all?
|
||||
end
|
||||
|
||||
def to_s = "(#{@operands.map(&:to_s).join(' && ')})"
|
||||
end
|
46
app/models/logic/binary_operator.rb
Normal file
46
app/models/logic/binary_operator.rb
Normal file
|
@ -0,0 +1,46 @@
|
|||
class Logic::BinaryOperator < Logic::Term
|
||||
attr_reader :left, :right
|
||||
|
||||
def initialize(left, right)
|
||||
@left, @right = left, right
|
||||
end
|
||||
|
||||
def to_h
|
||||
{
|
||||
"op" => self.class.name,
|
||||
"left" => @left.to_h,
|
||||
"right" => @right.to_h
|
||||
}
|
||||
end
|
||||
|
||||
def self.from_h(h)
|
||||
self.new(Logic.from_h(h['left']), Logic.from_h(h['right']))
|
||||
end
|
||||
|
||||
def errors(stable_ids = [])
|
||||
errors = []
|
||||
|
||||
if @left.type != :number || @right.type != :number
|
||||
errors += ["les types sont incompatibles : #{self}"]
|
||||
end
|
||||
|
||||
errors + @left.errors(stable_ids) + @right.errors(stable_ids)
|
||||
end
|
||||
|
||||
def type = :boolean
|
||||
|
||||
def compute(champs = [])
|
||||
l = @left.compute(champs)
|
||||
r = @right.compute(champs)
|
||||
|
||||
l.send(operation, r)
|
||||
end
|
||||
|
||||
def to_s = "(#{@left} #{operation} #{@right})"
|
||||
|
||||
def ==(other)
|
||||
self.class == other.class &&
|
||||
@left == other.left &&
|
||||
@right == other.right
|
||||
end
|
||||
end
|
76
app/models/logic/champ_value.rb
Normal file
76
app/models/logic/champ_value.rb
Normal file
|
@ -0,0 +1,76 @@
|
|||
class Logic::ChampValue < Logic::Term
|
||||
attr_reader :stable_id
|
||||
|
||||
def initialize(stable_id)
|
||||
@stable_id = stable_id
|
||||
end
|
||||
|
||||
def compute(champs)
|
||||
case type_de_champ.type_champ
|
||||
when all_types.fetch(:yes_no),
|
||||
all_types.fetch(:checkbox)
|
||||
champ(champs).true?
|
||||
when all_types.fetch(:integer_number), all_types.fetch(:decimal_number)
|
||||
champ(champs).for_api
|
||||
when all_types.fetch(:drop_down_list), all_types.fetch(:text)
|
||||
champ(champs).value
|
||||
end
|
||||
end
|
||||
|
||||
def to_s = "#{type_de_champ.libelle} Nº#{stable_id}"
|
||||
|
||||
def type
|
||||
case type_de_champ.type_champ
|
||||
when all_types.fetch(:yes_no),
|
||||
all_types.fetch(:checkbox)
|
||||
:boolean
|
||||
when all_types.fetch(:integer_number), all_types.fetch(:decimal_number)
|
||||
:number
|
||||
when all_types.fetch(:text)
|
||||
:string
|
||||
when all_types.fetch(:drop_down_list)
|
||||
:enum
|
||||
end
|
||||
end
|
||||
|
||||
def errors(stable_ids)
|
||||
if !stable_ids.include?(stable_id)
|
||||
["le type de champ stable_id=#{stable_id} n'est pas disponible"]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def to_h
|
||||
{
|
||||
"op" => self.class.name,
|
||||
"stable_id" => @stable_id
|
||||
}
|
||||
end
|
||||
|
||||
def self.from_h(h)
|
||||
self.new(h['stable_id'])
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
self.class == other.class && @stable_id == other.stable_id
|
||||
end
|
||||
|
||||
def options
|
||||
type_de_champ.drop_down_list_enabled_non_empty_options
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def type_de_champ
|
||||
TypeDeChamp.find_by(stable_id: stable_id)
|
||||
end
|
||||
|
||||
def champ(champs)
|
||||
champs.find { |c| c.stable_id == stable_id }
|
||||
end
|
||||
|
||||
def all_types
|
||||
TypeDeChamp.type_champs
|
||||
end
|
||||
end
|
39
app/models/logic/constant.rb
Normal file
39
app/models/logic/constant.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
class Logic::Constant < Logic::Term
|
||||
attr_reader :value
|
||||
|
||||
def initialize(value)
|
||||
@value = value
|
||||
end
|
||||
|
||||
def compute(_champs = nil) = @value
|
||||
|
||||
def to_s = @value.to_s
|
||||
|
||||
def type
|
||||
case @value
|
||||
when TrueClass, FalseClass
|
||||
:boolean
|
||||
when Integer, Float
|
||||
:number
|
||||
else
|
||||
@value.class.name.downcase.to_sym
|
||||
end
|
||||
end
|
||||
|
||||
def errors(_stable_ids = nil) = []
|
||||
|
||||
def to_h
|
||||
{
|
||||
"op" => self.class.name,
|
||||
"value" => @value
|
||||
}
|
||||
end
|
||||
|
||||
def self.from_h(h)
|
||||
self.new(h['value'])
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
self.class == other.class && @value == other.value
|
||||
end
|
||||
end
|
21
app/models/logic/empty.rb
Normal file
21
app/models/logic/empty.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
class Logic::Empty < Logic::Term
|
||||
def to_s = "empty member"
|
||||
|
||||
def type = :empty
|
||||
|
||||
def errors(_stable_ids = nil) = ['empty']
|
||||
|
||||
def to_h
|
||||
{
|
||||
"op" => self.class.name
|
||||
}
|
||||
end
|
||||
|
||||
def self.from_h(_h)
|
||||
self.new
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
self.class == other.class
|
||||
end
|
||||
end
|
7
app/models/logic/empty_operator.rb
Normal file
7
app/models/logic/empty_operator.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
class Logic::EmptyOperator < Logic::BinaryOperator
|
||||
def to_s = "empty operator"
|
||||
|
||||
def type = :empty
|
||||
|
||||
def errors(_stable_ids = nil) = ['empty']
|
||||
end
|
18
app/models/logic/eq.rb
Normal file
18
app/models/logic/eq.rb
Normal file
|
@ -0,0 +1,18 @@
|
|||
class Logic::Eq < Logic::BinaryOperator
|
||||
def operation = :==
|
||||
|
||||
def errors(stable_ids = [])
|
||||
errors = []
|
||||
|
||||
if !Logic.compatible_type?(@left, @right)
|
||||
errors += ["les types sont incompatibles : #{self}"]
|
||||
end
|
||||
|
||||
errors + @left.errors(stable_ids) + @right.errors(stable_ids)
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
self.class == other.class &&
|
||||
[@left, @right].permutation.any? { |p| p == [other.left, other.right] }
|
||||
end
|
||||
end
|
3
app/models/logic/greater_than.rb
Normal file
3
app/models/logic/greater_than.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class Logic::GreaterThan < Logic::BinaryOperator
|
||||
def operation = :>
|
||||
end
|
3
app/models/logic/greater_than_eq.rb
Normal file
3
app/models/logic/greater_than_eq.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class Logic::GreaterThanEq < Logic::BinaryOperator
|
||||
def operation = :>=
|
||||
end
|
3
app/models/logic/less_than.rb
Normal file
3
app/models/logic/less_than.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class Logic::LessThan < Logic::BinaryOperator
|
||||
def operation = :<
|
||||
end
|
3
app/models/logic/less_than_eq.rb
Normal file
3
app/models/logic/less_than_eq.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class Logic::LessThanEq < Logic::BinaryOperator
|
||||
def operation = :<=
|
||||
end
|
43
app/models/logic/n_ary_operator.rb
Normal file
43
app/models/logic/n_ary_operator.rb
Normal file
|
@ -0,0 +1,43 @@
|
|||
class Logic::NAryOperator < Logic::Term
|
||||
attr_reader :operands
|
||||
|
||||
def initialize(operands)
|
||||
@operands = operands
|
||||
end
|
||||
|
||||
def to_h
|
||||
{
|
||||
"op" => self.class.name,
|
||||
"operands" => @operands.map(&:to_h)
|
||||
}
|
||||
end
|
||||
|
||||
def self.from_h(h)
|
||||
self.new(h['operands'].map { |operand_h| Logic.from_h(operand_h) })
|
||||
end
|
||||
|
||||
def errors(stable_ids = [])
|
||||
errors = []
|
||||
|
||||
if @operands.empty?
|
||||
errors += ["opérateur '#{operator_name}' vide"]
|
||||
end
|
||||
|
||||
not_booleans = @operands.filter { |operand| operand.type != :boolean }
|
||||
if not_booleans.present?
|
||||
errors += ["'#{operator_name}' ne contient pas que des booléens : #{not_booleans.map(&:to_s).join(', ')}"]
|
||||
end
|
||||
|
||||
errors + @operands.flat_map { |operand| operand.errors(stable_ids) }
|
||||
end
|
||||
|
||||
def type = :boolean
|
||||
|
||||
def ==(other)
|
||||
self.class == other.class &&
|
||||
@operands.count == other.operands.count &&
|
||||
@operands.all? do |operand|
|
||||
@operands.count { |o| o == operand } == other.operands.count { |o| o == operand }
|
||||
end
|
||||
end
|
||||
end
|
11
app/models/logic/or.rb
Normal file
11
app/models/logic/or.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class Logic::Or < Logic::NAryOperator
|
||||
attr_reader :operands
|
||||
|
||||
def operator_name = 'Ou'
|
||||
|
||||
def compute(champs = [])
|
||||
@operands.map { |operand| operand.compute(champs) }.any?
|
||||
end
|
||||
|
||||
def to_s = "(#{@operands.map(&:to_s).join(' || ')})"
|
||||
end
|
5
app/models/logic/term.rb
Normal file
5
app/models/logic/term.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class Logic::Term
|
||||
def to_json
|
||||
to_h.to_json
|
||||
end
|
||||
end
|
|
@ -740,6 +740,10 @@ class Procedure < ApplicationRecord
|
|||
|
||||
move_new_children_to_new_parent_coordinate(new_draft)
|
||||
|
||||
# they are not aware of the new tdcs
|
||||
new_draft.types_de_champ_public.reset
|
||||
new_draft.types_de_champ_private.reset
|
||||
|
||||
new_draft
|
||||
end
|
||||
end
|
||||
|
|
|
@ -30,12 +30,16 @@ class ProcedureRevision < ApplicationRecord
|
|||
|
||||
scope :ordered, -> { order(:created_at) }
|
||||
|
||||
validate :conditions_are_valid?
|
||||
|
||||
def build_champs
|
||||
types_de_champ_public.map { |tdc| tdc.build_champ(revision: self) }
|
||||
# reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc
|
||||
types_de_champ_public.reload.map { |tdc| tdc.build_champ(revision: self) }
|
||||
end
|
||||
|
||||
def build_champs_private
|
||||
types_de_champ_private.map { |tdc| tdc.build_champ(revision: self) }
|
||||
# reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc
|
||||
types_de_champ_private.reload.map { |tdc| tdc.build_champ(revision: self) }
|
||||
end
|
||||
|
||||
def add_type_de_champ(params)
|
||||
|
@ -62,6 +66,10 @@ class ProcedureRevision < ApplicationRecord
|
|||
reorder(coordinate.siblings)
|
||||
end
|
||||
|
||||
# they are not aware of the addition
|
||||
types_de_champ_public.reset
|
||||
types_de_champ_private.reset
|
||||
|
||||
tdc
|
||||
rescue => e
|
||||
TypeDeChamp.new.tap { |tdc| tdc.errors.add(:base, e.message) }
|
||||
|
@ -99,6 +107,10 @@ class ProcedureRevision < ApplicationRecord
|
|||
children.each(&:destroy_if_orphan)
|
||||
tdc.destroy_if_orphan
|
||||
|
||||
# they are not aware of the removal
|
||||
types_de_champ_public.reset
|
||||
types_de_champ_private.reset
|
||||
|
||||
reorder(coordinate.siblings)
|
||||
|
||||
coordinate
|
||||
|
@ -465,4 +477,14 @@ class ProcedureRevision < ApplicationRecord
|
|||
last.present? ? last.position + 1 : 0
|
||||
end
|
||||
end
|
||||
|
||||
def conditions_are_valid?
|
||||
stable_ids = types_de_champ_public.map(&:stable_id)
|
||||
|
||||
types_de_champ_public
|
||||
.map.with_index
|
||||
.filter_map { |tdc, i| tdc.condition.present? ? [tdc, i] : nil }
|
||||
.flat_map { |tdc, i| tdc.condition.errors(stable_ids.take(i)) }
|
||||
.each { |message| errors.add(:condition, message) }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
# Table name: types_de_champ
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# condition :jsonb
|
||||
# description :text
|
||||
# libelle :string
|
||||
# mandatory :boolean default(FALSE)
|
||||
|
@ -77,6 +78,22 @@ class TypeDeChamp < ApplicationRecord
|
|||
|
||||
serialize :options, WithIndifferentAccess
|
||||
|
||||
class ConditionSerializer
|
||||
def self.load(condition)
|
||||
if condition.present?
|
||||
Logic.from_h(condition)
|
||||
end
|
||||
end
|
||||
|
||||
def self.dump(condition)
|
||||
if condition.present?
|
||||
condition.to_h
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
serialize :condition, ConditionSerializer
|
||||
|
||||
after_initialize :set_dynamic_type
|
||||
after_create :populate_stable_id
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class AddConditionColumnToTypeDeChamp < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :types_de_champ, :condition, :jsonb
|
||||
end
|
||||
end
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2022_05_20_173939) do
|
||||
ActiveRecord::Schema.define(version: 2022_05_31_100040) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
|
@ -787,6 +787,7 @@ ActiveRecord::Schema.define(version: 2022_05_20_173939) do
|
|||
end
|
||||
|
||||
create_table "types_de_champ", id: :serial, force: :cascade do |t|
|
||||
t.jsonb "condition"
|
||||
t.datetime "created_at"
|
||||
t.text "description"
|
||||
t.string "libelle"
|
||||
|
|
18
spec/models/logic/and_spec.rb
Normal file
18
spec/models/logic/and_spec.rb
Normal file
|
@ -0,0 +1,18 @@
|
|||
describe Logic::And do
|
||||
include Logic
|
||||
|
||||
describe '#compute' do
|
||||
it { expect(and_from([true, true, true]).compute).to be true }
|
||||
it { expect(and_from([true, true, false]).compute).to be false }
|
||||
end
|
||||
|
||||
describe '#to_s' do
|
||||
it do
|
||||
expect(and_from([true, false, true]).to_s).to eq "(true && false && true)"
|
||||
end
|
||||
end
|
||||
|
||||
def and_from(boolean_to_constants)
|
||||
ds_and(boolean_to_constants.map { |b| constant(b) })
|
||||
end
|
||||
end
|
52
spec/models/logic/binary_operator_spec.rb
Normal file
52
spec/models/logic/binary_operator_spec.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
include Logic
|
||||
|
||||
describe Logic::BinaryOperator do
|
||||
let(:two_greater_than_one) { greater_than(constant(2), constant(1)) }
|
||||
|
||||
describe '#type' do
|
||||
it { expect(two_greater_than_one.type).to eq(:boolean) }
|
||||
end
|
||||
|
||||
describe '#to_s' do
|
||||
it { expect(two_greater_than_one.to_s).to eq('(2 > 1)') }
|
||||
end
|
||||
|
||||
describe '#==' do
|
||||
it { expect(two_greater_than_one).to eq(greater_than(constant(2), constant(1))) }
|
||||
it { expect(two_greater_than_one).not_to eq(greater_than(constant(1), constant(2))) }
|
||||
end
|
||||
|
||||
describe '#errors' do
|
||||
it { expect(greater_than(constant(1), constant(true)).errors).to eq(['les types sont incompatibles : (1 > true)']) }
|
||||
end
|
||||
end
|
||||
|
||||
describe Logic::GreaterThan do
|
||||
it 'computes' do
|
||||
expect(greater_than(constant(1), constant(1)).compute).to be(false)
|
||||
expect(greater_than(constant(2), constant(1)).compute).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
describe Logic::GreaterThanEq do
|
||||
it 'computes' do
|
||||
expect(greater_than_eq(constant(0), constant(1)).compute).to be(false)
|
||||
expect(greater_than_eq(constant(1), constant(1)).compute).to be(true)
|
||||
expect(greater_than_eq(constant(2), constant(1)).compute).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
describe Logic::LessThan do
|
||||
it 'computes' do
|
||||
expect(less_than(constant(1), constant(1)).compute).to be(false)
|
||||
expect(less_than(constant(1), constant(2)).compute).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
describe Logic::LessThanEq do
|
||||
it 'computes' do
|
||||
expect(less_than_eq(constant(0), constant(1)).compute).to be(true)
|
||||
expect(less_than_eq(constant(1), constant(1)).compute).to be(true)
|
||||
expect(less_than_eq(constant(2), constant(1)).compute).to be(false)
|
||||
end
|
||||
end
|
64
spec/models/logic/champ_value_spec.rb
Normal file
64
spec/models/logic/champ_value_spec.rb
Normal file
|
@ -0,0 +1,64 @@
|
|||
describe Logic::ChampValue do
|
||||
include Logic
|
||||
|
||||
subject { champ_value(champ.stable_id).compute([champ]) }
|
||||
|
||||
context 'yes_no tdc' do
|
||||
let(:value) { 'true' }
|
||||
let(:champ) { create(:champ_yes_no, value: value) }
|
||||
|
||||
it { expect(champ_value(champ.stable_id).type).to eq(:boolean) }
|
||||
|
||||
context 'with true value' do
|
||||
it { is_expected.to be(true) }
|
||||
end
|
||||
|
||||
context 'with false value' do
|
||||
let(:value) { 'false' }
|
||||
|
||||
it { is_expected.to be(false) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'text tdc' do
|
||||
let(:champ) { create(:champ_text, value: 'text') }
|
||||
|
||||
it { expect(champ_value(champ.stable_id).type).to eq(:string) }
|
||||
it { is_expected.to eq('text') }
|
||||
end
|
||||
|
||||
context 'integer tdc' do
|
||||
let(:champ) { create(:champ_integer_number, value: '42') }
|
||||
|
||||
it { expect(champ_value(champ.stable_id).type).to eq(:number) }
|
||||
it { is_expected.to eq(42) }
|
||||
end
|
||||
|
||||
context 'decimal tdc' do
|
||||
let(:champ) { create(:champ_decimal_number, value: '42.01') }
|
||||
|
||||
it { expect(champ_value(champ.stable_id).type).to eq(:number) }
|
||||
it { is_expected.to eq(42.01) }
|
||||
end
|
||||
|
||||
context 'dropdown tdc' do
|
||||
let(:champ) { create(:champ_drop_down_list, value: 'choix 1') }
|
||||
|
||||
it { expect(champ_value(champ.stable_id).type).to eq(:enum) }
|
||||
it { is_expected.to eq('choix 1') }
|
||||
end
|
||||
|
||||
context 'checkbox tdc' do
|
||||
let(:champ) { create(:champ_checkbox, value: 'on') }
|
||||
|
||||
it { expect(champ_value(champ.stable_id).type).to eq(:boolean) }
|
||||
it { is_expected.to eq(true) }
|
||||
end
|
||||
|
||||
describe 'errors' do
|
||||
let(:champ) { create(:champ) }
|
||||
|
||||
it { expect(champ_value(champ.stable_id).errors([champ.stable_id])).to be_empty }
|
||||
it { expect(champ_value(champ.stable_id).errors(['other stable ids'])).to eq(["le type de champ stable_id=#{champ.stable_id} n'est pas disponible"]) }
|
||||
end
|
||||
end
|
24
spec/models/logic/constant_spec.rb
Normal file
24
spec/models/logic/constant_spec.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
describe Logic::Constant do
|
||||
include Logic
|
||||
|
||||
describe '#compute' do
|
||||
it { expect(constant(1).compute).to eq(1) }
|
||||
end
|
||||
|
||||
describe '#type' do
|
||||
it { expect(constant(1).type).to eq(:number) }
|
||||
it { expect(constant(1.0).type).to eq(:number) }
|
||||
it { expect(constant('a').type).to eq(:string) }
|
||||
it { expect(constant(true).type).to eq(:boolean) }
|
||||
it { expect(constant(false).type).to eq(:boolean) }
|
||||
end
|
||||
|
||||
describe '#errors' do
|
||||
it { expect(constant(1).errors).to eq([]) }
|
||||
end
|
||||
|
||||
describe '#==' do
|
||||
it { expect(constant(1)).to eq(constant(1)) }
|
||||
it { expect(constant(1)).not_to eq(constant('a')) }
|
||||
end
|
||||
end
|
16
spec/models/logic/empty_spec.rb
Normal file
16
spec/models/logic/empty_spec.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
describe Logic::Constant do
|
||||
include Logic
|
||||
|
||||
describe '#type' do
|
||||
it { expect(empty.type).to eq(:empty) }
|
||||
end
|
||||
|
||||
describe '#errors' do
|
||||
it { expect(empty.errors).to eq(['empty']) }
|
||||
end
|
||||
|
||||
describe '#==' do
|
||||
it { expect(empty).to eq(empty) }
|
||||
it { expect(empty).not_to eq(constant(true)) }
|
||||
end
|
||||
end
|
17
spec/models/logic/eq_spec.rb
Normal file
17
spec/models/logic/eq_spec.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
describe Logic::Eq do
|
||||
include Logic
|
||||
|
||||
describe '#compute' do
|
||||
it { expect(ds_eq(constant(1), constant(1)).compute).to be(true) }
|
||||
it { expect(ds_eq(constant(1), constant(2)).compute).to be(false) }
|
||||
end
|
||||
|
||||
describe '#errors' do
|
||||
it { expect(ds_eq(constant(true), constant(true)).errors).to be_empty }
|
||||
it { expect(ds_eq(constant(true), constant(1)).errors).to eq(["les types sont incompatibles : (true == 1)"]) }
|
||||
end
|
||||
|
||||
describe '#==' do
|
||||
it { expect(ds_eq(constant(true), constant(false))).to eq(ds_eq(constant(false), constant(true))) }
|
||||
end
|
||||
end
|
29
spec/models/logic/n_ary_operator_spec.rb
Normal file
29
spec/models/logic/n_ary_operator_spec.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
describe Logic::NAryOperator do
|
||||
include Logic
|
||||
|
||||
describe '#errors' do
|
||||
it { expect(ds_and([]).errors).to eq(["opérateur 'Et' vide"]) }
|
||||
it { expect(ds_and([constant(1), constant('toto')]).errors).to eq(["'Et' ne contient pas que des booléens : 1, toto"]) }
|
||||
it { expect(ds_and([double(type: :boolean, errors: ['from double'])]).errors).to eq(["from double"]) }
|
||||
end
|
||||
|
||||
describe '#==' do
|
||||
it do
|
||||
expect(and_from([true, true, false])).to eq(and_from([false, true, true]))
|
||||
expect(and_from([true, true, false])).not_to eq(and_from([false, false, true]))
|
||||
|
||||
# perf test
|
||||
left = [false, false] + Array.new(10) { true }
|
||||
right = [false] + Array.new(11) { true }
|
||||
expect(and_from(left)).not_to eq(and_from(right))
|
||||
|
||||
left = (1..10).to_a
|
||||
right = (1..10).to_a.reverse
|
||||
expect(and_from(left)).to eq(and_from(right))
|
||||
end
|
||||
end
|
||||
|
||||
def and_from(boolean_to_constants)
|
||||
ds_and(boolean_to_constants.map { |b| constant(b) })
|
||||
end
|
||||
end
|
82
spec/models/logic_spec.rb
Normal file
82
spec/models/logic_spec.rb
Normal file
|
@ -0,0 +1,82 @@
|
|||
describe Logic do
|
||||
include Logic
|
||||
|
||||
it 'serializes deserializes' do
|
||||
expect(Logic.from_h(constant(1).to_h)).to eq(constant(1))
|
||||
expect(Logic.from_json(constant(1).to_json)).to eq(constant(1))
|
||||
|
||||
expect(Logic.from_h(empty.to_h)).to eq(empty)
|
||||
|
||||
expect(Logic.from_h(champ_value(1).to_h)).to eq(champ_value(1))
|
||||
|
||||
expect(Logic.from_h(greater_than(constant(1), constant(2)).to_h)).to eq(greater_than(constant(1), constant(2)))
|
||||
|
||||
expect(Logic.from_h(ds_and([constant(true), constant(true), constant(false)]).to_h))
|
||||
.to eq(ds_and([constant(true), constant(true), constant(false)]))
|
||||
end
|
||||
|
||||
describe '.ensure_compatibility_from_left' do
|
||||
subject { Logic.ensure_compatibility_from_left(condition) }
|
||||
|
||||
context 'when it s fine' do
|
||||
let(:condition) { greater_than(constant(1), constant(1)) }
|
||||
|
||||
it { is_expected.to eq(condition) }
|
||||
end
|
||||
|
||||
context 'when empty equal true' do
|
||||
let(:condition) { ds_eq(empty, constant(true)) }
|
||||
|
||||
it { is_expected.to eq(empty_operator(empty, empty)) }
|
||||
end
|
||||
|
||||
context 'when true greater_than 1' do
|
||||
let(:condition) { greater_than(constant(true), constant(0)) }
|
||||
|
||||
it { is_expected.to eq(ds_eq(constant(true), constant(true))) }
|
||||
end
|
||||
|
||||
context 'when number empty operator true' do
|
||||
let(:condition) { empty_operator(constant(1), constant(true)) }
|
||||
|
||||
it { is_expected.to eq(ds_eq(constant(1), constant(0))) }
|
||||
end
|
||||
|
||||
context 'when string empty operator true' do
|
||||
let(:condition) { empty_operator(constant('a'), constant(true)) }
|
||||
|
||||
it { is_expected.to eq(ds_eq(constant('a'), constant(''))) }
|
||||
end
|
||||
|
||||
context 'when dropdown empty operator true' do
|
||||
let(:drop_down) { create(:type_de_champ_drop_down_list) }
|
||||
let(:first_option) { drop_down.drop_down_list_enabled_non_empty_options.first }
|
||||
let(:condition) { empty_operator(champ_value(drop_down), constant(true)) }
|
||||
|
||||
it { is_expected.to eq(ds_eq(champ_value(drop_down), constant(first_option))) }
|
||||
end
|
||||
end
|
||||
|
||||
describe '.compatible_type?' do
|
||||
it { expect(Logic.compatible_type?(constant(true), constant(true))).to be true }
|
||||
it { expect(Logic.compatible_type?(constant(1), constant(true))).to be false }
|
||||
|
||||
context 'with a dropdown' do
|
||||
let(:drop_down) { create(:type_de_champ_drop_down_list) }
|
||||
let(:first_option) { drop_down.drop_down_list_enabled_non_empty_options.first }
|
||||
|
||||
it do
|
||||
expect(Logic.compatible_type?(champ_value(drop_down.stable_id), constant(first_option))).to be true
|
||||
expect(Logic.compatible_type?(champ_value(drop_down.stable_id), constant('a'))).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'priority' do
|
||||
# (false && true) || true = true
|
||||
it { expect(ds_or([ds_and([constant(false), constant(true)]), constant(true)]).compute).to be true }
|
||||
|
||||
# false && (true || true) = false
|
||||
it { expect(ds_and([constant(false), ds_or([constant(true), constant(true)])]).compute).to be false }
|
||||
end
|
||||
end
|
17
spec/models/or_spec.rb
Normal file
17
spec/models/or_spec.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
describe Logic::Or do
|
||||
include Logic
|
||||
|
||||
describe '#compute' do
|
||||
it { expect(or_from([true, true, true]).compute).to be true }
|
||||
it { expect(or_from([true, true, false]).compute).to be true }
|
||||
it { expect(or_from([false, false, false]).compute).to be false }
|
||||
end
|
||||
|
||||
describe '#to_s' do
|
||||
it { expect(or_from([true, false, true]).to_s).to eq "(true || false || true)" }
|
||||
end
|
||||
|
||||
def or_from(boolean_to_constants)
|
||||
ds_or(boolean_to_constants.map { |b| constant(b) })
|
||||
end
|
||||
end
|
|
@ -623,4 +623,49 @@ describe ProcedureRevision do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conditions_are_valid' do
|
||||
include Logic
|
||||
|
||||
def first_champ = procedure.draft_revision.types_de_champ_public.first
|
||||
|
||||
def second_champ = procedure.draft_revision.types_de_champ_public.second
|
||||
|
||||
let(:procedure) { create(:procedure, :with_type_de_champ, types_de_champ_count: 2) }
|
||||
let(:draft_revision) { procedure.draft_revision }
|
||||
let(:condition) { nil }
|
||||
|
||||
subject do
|
||||
draft_revision.save
|
||||
draft_revision.errors
|
||||
end
|
||||
|
||||
before { second_champ.update(condition: condition) }
|
||||
|
||||
context 'when a champ has a valid condition (type)' do
|
||||
let(:condition) { ds_eq(constant(true), constant(true)) }
|
||||
|
||||
it { is_expected.to be_empty }
|
||||
end
|
||||
|
||||
context 'when a champ has a valid condition: needed tdc is up in the forms' do
|
||||
let(:condition) { ds_eq(constant('oui'), champ_value(first_champ.stable_id)) }
|
||||
|
||||
it { is_expected.to be_empty }
|
||||
end
|
||||
|
||||
context 'when a champ has an invalid condition' do
|
||||
let(:condition) { ds_eq(constant(true), constant(1)) }
|
||||
|
||||
it { expect(subject.first.attribute).to eq(:condition) }
|
||||
end
|
||||
|
||||
context 'when a champ has an invalid condition: needed tdc is down in the forms' do
|
||||
let(:need_second_champ) { ds_eq(constant('oui'), champ_value(second_champ.stable_id)) }
|
||||
|
||||
before { first_champ.update(condition: need_second_champ) }
|
||||
|
||||
it { expect(subject.first.attribute).to eq(:condition) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,4 +11,15 @@ describe TypeDeChamp do
|
|||
expect(procedure.types_de_champ_private.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'condition' do
|
||||
let(:type_de_champ) { create(:type_de_champ) }
|
||||
let(:condition) { Logic::Eq.new(Logic::Constant.new(true), Logic::Constant.new(true)) }
|
||||
|
||||
it 'saves and reload the condition' do
|
||||
type_de_champ.update(condition: condition)
|
||||
type_de_champ.reload
|
||||
expect(type_de_champ.condition).to eq(condition)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue