mirror of https://github.com/docusealco/docuseal
* feat: add pluggable field detection system (v0.14.0) Add external field detection via Ruby scripts and YAML profiles. Two plugin mechanisms supported side-by-side: 1. Ruby scripts (FIELD_DETECTION_SCRIPTS_DIR) - full-power handlers registered via Templates::FieldDetection.register(name, handler) 2. YAML profiles (FIELD_DETECTION_CONFIG_DIR) - declarative configs with text anchors + relative offsets OR absolute positions Core changes: - lib/templates/field_detection.rb: registry, loader, dispatcher - lib/templates/field_detection/config_based.rb: YAML profile engine with anchor-based and absolute positioning, negative page indices - Controller accepts algorithm param, dispatches accordingly - Split-button UI: main action = ML detection, dropdown = profiles - RSpec tests for both modules with fixture YAML profiles No business-specific algorithms ship with docuseal - all are injected at deploy time via ConfigMap/mounted volumes. * fix: use proper user/account setup in config_based spec * fix: include submitters in algorithm detection response --------- Co-authored-by: Bob Develop <developbob50@gmail.com>pull/639/head
parent
d08179854b
commit
76a60244e0
@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module FieldDetection
|
||||
CONFIG_DIR = ENV.fetch('FIELD_DETECTION_CONFIG_DIR',
|
||||
File.join(ExternalConfig::CONFIG_DIR, 'field_detection'))
|
||||
SCRIPTS_DIR = ENV.fetch('FIELD_DETECTION_SCRIPTS_DIR',
|
||||
File.join(ExternalConfig::CONFIG_DIR, 'field_detection_scripts'))
|
||||
|
||||
class << self
|
||||
def register(name, handler)
|
||||
registered_scripts[name.to_s.downcase.strip] = handler
|
||||
end
|
||||
|
||||
def registered_scripts
|
||||
@registered_scripts ||= {}
|
||||
end
|
||||
|
||||
def load_scripts!
|
||||
return if @scripts_loaded
|
||||
|
||||
@scripts_loaded = true
|
||||
return unless Dir.exist?(SCRIPTS_DIR)
|
||||
|
||||
Dir.glob(File.join(SCRIPTS_DIR, '*.rb')).each do |path|
|
||||
load(path)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[FieldDetection] Failed to load script #{path}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def available_algorithms
|
||||
load_scripts!
|
||||
registered_scripts.merge(external_algorithms)
|
||||
end
|
||||
|
||||
def profile_names
|
||||
available_algorithms.keys.sort
|
||||
end
|
||||
|
||||
def call(template, algorithm, documents = nil)
|
||||
algo_key = algorithm.to_s.downcase.strip
|
||||
handler = available_algorithms[algo_key]
|
||||
|
||||
raise ArgumentError, "Unknown algorithm: '#{algorithm}'" unless handler
|
||||
|
||||
if handler.is_a?(Hash)
|
||||
Templates::FieldDetection::ConfigBased.call(template, handler, documents)
|
||||
elsif handler.respond_to?(:call)
|
||||
handler.call(template, documents)
|
||||
else
|
||||
raise ArgumentError, "Invalid handler for algorithm '#{algo_key}'"
|
||||
end
|
||||
end
|
||||
|
||||
def external_algorithms
|
||||
return {} unless Dir.exist?(CONFIG_DIR)
|
||||
|
||||
Dir.glob(File.join(CONFIG_DIR, '*.{yml,yaml}')).each_with_object({}) do |path, hash|
|
||||
config = YAML.safe_load_file(path, permitted_classes: [Regexp])
|
||||
hash[File.basename(path, File.extname(path))] = config if config.is_a?(Hash)
|
||||
rescue StandardError
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
def reset!
|
||||
@registered_scripts = {}
|
||||
@scripts_loaded = false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,176 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module FieldDetection
|
||||
module ConfigBased
|
||||
module_function
|
||||
|
||||
def call(template, config, documents = nil)
|
||||
documents ||= template.schema_documents.preload(:blob)
|
||||
attachment = documents.first
|
||||
|
||||
return [] unless attachment
|
||||
|
||||
submitter_map = ensure_submitters(template, config)
|
||||
fields = build_fields(template, config, attachment, submitter_map)
|
||||
|
||||
template.fields = fields
|
||||
template.save!
|
||||
|
||||
fields
|
||||
end
|
||||
|
||||
def ensure_submitters(template, config)
|
||||
submitter_map = {}
|
||||
config_submitters = config['submitters'] || []
|
||||
|
||||
config_submitters.each do |cs|
|
||||
name = cs['name'].to_s.strip
|
||||
next if name.blank?
|
||||
|
||||
existing = template.submitters.find { |s| s['name'].to_s.downcase == name.downcase }
|
||||
|
||||
if existing
|
||||
submitter_map[name.downcase] = existing['uuid']
|
||||
else
|
||||
uuid = SecureRandom.uuid
|
||||
template.submitters << { 'name' => name, 'uuid' => uuid }
|
||||
submitter_map[name.downcase] = uuid
|
||||
end
|
||||
end
|
||||
|
||||
submitter_map
|
||||
end
|
||||
|
||||
def build_fields(template, config, attachment, submitter_map)
|
||||
config_fields = config['fields'] || []
|
||||
doc = Pdfium::Document.open_bytes(attachment.blob.download)
|
||||
page_texts = extract_page_texts(doc)
|
||||
|
||||
config_fields.filter_map do |field_config|
|
||||
build_field(field_config, template, attachment, submitter_map, doc, page_texts)
|
||||
end
|
||||
ensure
|
||||
doc&.close
|
||||
end
|
||||
|
||||
def build_field(field_config, template, attachment, submitter_map, doc, page_texts)
|
||||
submitter_key = field_config['submitter'].to_s.downcase.strip
|
||||
submitter_uuid = submitter_map[submitter_key] || template.submitters.first&.dig('uuid')
|
||||
|
||||
return nil unless submitter_uuid
|
||||
|
||||
area = resolve_area(field_config, doc, page_texts, attachment)
|
||||
|
||||
return nil unless area
|
||||
|
||||
{
|
||||
'uuid' => SecureRandom.uuid,
|
||||
'submitter_uuid' => submitter_uuid,
|
||||
'name' => field_config['name'].to_s,
|
||||
'type' => field_config['type'].to_s,
|
||||
'required' => field_config.fetch('required', true),
|
||||
'preferences' => field_config.fetch('preferences', {}),
|
||||
'areas' => [area]
|
||||
}
|
||||
end
|
||||
|
||||
def resolve_area(field_config, doc, page_texts, attachment)
|
||||
if field_config['anchor']
|
||||
resolve_anchor_area(field_config, doc, page_texts, attachment)
|
||||
elsif field_config['position']
|
||||
resolve_absolute_area(field_config, doc, attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_anchor_area(field_config, doc, page_texts, attachment)
|
||||
anchor = field_config['anchor']
|
||||
anchor_text = anchor['text'].to_s
|
||||
anchor_page = anchor['page'] || 0
|
||||
area_config = field_config['area'] || {}
|
||||
|
||||
resolved_page = resolve_page_index(anchor_page, doc.page_count)
|
||||
|
||||
return nil unless resolved_page && resolved_page >= 0 && resolved_page < doc.page_count
|
||||
|
||||
text_position = find_text_on_page(page_texts, resolved_page, anchor_text)
|
||||
|
||||
return nil unless text_position
|
||||
|
||||
{
|
||||
'attachment_uuid' => attachment.uuid,
|
||||
'page' => resolved_page,
|
||||
'x' => clamp(text_position[:x] + (area_config['x_offset'] || 0).to_f),
|
||||
'y' => clamp(text_position[:y] + (area_config['y_offset'] || 0).to_f),
|
||||
'w' => clamp_dimension(area_config['w'] || 0.25),
|
||||
'h' => clamp_dimension(area_config['h'] || 0.05)
|
||||
}
|
||||
end
|
||||
|
||||
def resolve_absolute_area(field_config, doc, attachment)
|
||||
position = field_config['position']
|
||||
page_index = resolve_page_index(position['page'] || 0, doc.page_count)
|
||||
|
||||
return nil unless page_index && page_index >= 0 && page_index < doc.page_count
|
||||
|
||||
{
|
||||
'attachment_uuid' => attachment.uuid,
|
||||
'page' => page_index,
|
||||
'x' => clamp(position['x'].to_f),
|
||||
'y' => clamp(position['y'].to_f),
|
||||
'w' => clamp_dimension(position['w'] || 0.25),
|
||||
'h' => clamp_dimension(position['h'] || 0.05)
|
||||
}
|
||||
end
|
||||
|
||||
def extract_page_texts(doc)
|
||||
(0...doc.page_count).map do |page_index|
|
||||
page = doc.get_page(page_index)
|
||||
nodes = page.text_nodes
|
||||
{ nodes: nodes, page_index: page_index }
|
||||
ensure
|
||||
page&.close
|
||||
end
|
||||
end
|
||||
|
||||
def find_text_on_page(page_texts, page_index, target_text)
|
||||
page_data = page_texts[page_index]
|
||||
|
||||
return nil unless page_data
|
||||
|
||||
target_lower = target_text.downcase
|
||||
|
||||
accumulated = ''
|
||||
first_node = nil
|
||||
|
||||
page_data[:nodes].each do |node|
|
||||
first_node = node if accumulated.empty?
|
||||
|
||||
accumulated += node.content
|
||||
|
||||
return { x: first_node.x, y: first_node.y } if accumulated.downcase.include?(target_lower)
|
||||
|
||||
accumulated = '' if accumulated.length > target_text.length * 3
|
||||
first_node = nil if accumulated.empty?
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_page_index(page, total_pages)
|
||||
return nil if total_pages.zero?
|
||||
|
||||
page = page.to_i
|
||||
page >= 0 ? page : total_pages + page
|
||||
end
|
||||
|
||||
def clamp(value)
|
||||
value.to_f.clamp(0.0, 1.0)
|
||||
end
|
||||
|
||||
def clamp_dimension(value)
|
||||
value.to_f.clamp(0.001, 1.0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,23 @@
|
||||
submitters:
|
||||
- name: signer
|
||||
fields:
|
||||
- name: "full-name"
|
||||
type: "text"
|
||||
submitter: "signer"
|
||||
required: true
|
||||
position:
|
||||
page: 0
|
||||
x: 0.10
|
||||
y: 0.20
|
||||
w: 0.30
|
||||
h: 0.04
|
||||
- name: "signature"
|
||||
type: "signature"
|
||||
submitter: "signer"
|
||||
required: true
|
||||
position:
|
||||
page: -1
|
||||
x: 0.50
|
||||
y: 0.80
|
||||
w: 0.25
|
||||
h: 0.05
|
||||
@ -0,0 +1,35 @@
|
||||
submitters:
|
||||
- name: seller
|
||||
- name: customer
|
||||
fields:
|
||||
- name: "signature-seller"
|
||||
type: "signature"
|
||||
submitter: "seller"
|
||||
required: true
|
||||
anchor:
|
||||
text: "Seller Signature"
|
||||
page: -1
|
||||
area:
|
||||
x_offset: 0.0
|
||||
y_offset: 0.02
|
||||
w: 0.25
|
||||
h: 0.05
|
||||
- name: "date-seller"
|
||||
type: "date"
|
||||
submitter: "seller"
|
||||
position:
|
||||
page: -1
|
||||
x: 0.60
|
||||
y: 0.85
|
||||
w: 0.15
|
||||
h: 0.04
|
||||
- name: "name-customer"
|
||||
type: "text"
|
||||
submitter: "customer"
|
||||
required: true
|
||||
position:
|
||||
page: 0
|
||||
x: 0.10
|
||||
y: 0.20
|
||||
w: 0.30
|
||||
h: 0.04
|
||||
@ -0,0 +1,207 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require 'templates/field_detection'
|
||||
require 'templates/field_detection/config_based'
|
||||
|
||||
RSpec.describe Templates::FieldDetection::ConfigBased do
|
||||
let(:user) { create(:user) }
|
||||
let(:template) { create(:template, account: user.account, author: user) }
|
||||
let(:attachment) { template.schema_documents.first }
|
||||
let(:documents) { template.schema_documents.preload(:blob) }
|
||||
|
||||
describe '.call' do
|
||||
context 'with absolute position fields' do
|
||||
let(:config) do
|
||||
{
|
||||
'submitters' => [{ 'name' => 'signer' }],
|
||||
'fields' => [
|
||||
{
|
||||
'name' => 'full-name',
|
||||
'type' => 'text',
|
||||
'submitter' => 'signer',
|
||||
'required' => true,
|
||||
'position' => { 'page' => 0, 'x' => 0.10, 'y' => 0.20, 'w' => 0.30, 'h' => 0.04 }
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'places fields at absolute coordinates' do
|
||||
fields = described_class.call(template, config, documents)
|
||||
|
||||
expect(fields.length).to eq(1)
|
||||
field = fields.first
|
||||
expect(field['name']).to eq('full-name')
|
||||
expect(field['type']).to eq('text')
|
||||
expect(field['required']).to be(true)
|
||||
expect(field['areas'].first['page']).to eq(0)
|
||||
expect(field['areas'].first['x']).to be_within(0.001).of(0.10)
|
||||
expect(field['areas'].first['y']).to be_within(0.001).of(0.20)
|
||||
expect(field['areas'].first['w']).to be_within(0.001).of(0.30)
|
||||
expect(field['areas'].first['h']).to be_within(0.001).of(0.04)
|
||||
end
|
||||
|
||||
it 'saves fields to the template' do
|
||||
described_class.call(template, config, documents)
|
||||
template.reload
|
||||
|
||||
expect(template.fields.length).to eq(1)
|
||||
expect(template.fields.first['name']).to eq('full-name')
|
||||
end
|
||||
|
||||
it 'assigns submitter_uuid from the config submitter map' do
|
||||
fields = described_class.call(template, config, documents)
|
||||
|
||||
signer = template.submitters.find { |s| s['name'].downcase == 'signer' }
|
||||
expect(signer).to be_present
|
||||
expect(fields.first['submitter_uuid']).to eq(signer['uuid'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with negative page index' do
|
||||
let(:config) do
|
||||
{
|
||||
'submitters' => [{ 'name' => 'signer' }],
|
||||
'fields' => [
|
||||
{
|
||||
'name' => 'last-page-sig',
|
||||
'type' => 'signature',
|
||||
'submitter' => 'signer',
|
||||
'position' => { 'page' => -1, 'x' => 0.50, 'y' => 0.80, 'w' => 0.25, 'h' => 0.05 }
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'resolves -1 to the last page' do
|
||||
fields = described_class.call(template, config, documents)
|
||||
|
||||
expect(fields.length).to eq(1)
|
||||
area = fields.first['areas'].first
|
||||
expect(area['page']).to be >= 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unknown submitter role' do
|
||||
let(:config) do
|
||||
{
|
||||
'submitters' => [],
|
||||
'fields' => [
|
||||
{
|
||||
'name' => 'orphan-field',
|
||||
'type' => 'text',
|
||||
'submitter' => 'nonexistent_role',
|
||||
'position' => { 'page' => 0, 'x' => 0.10, 'y' => 0.20, 'w' => 0.30, 'h' => 0.04 }
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'falls back to first existing submitter' do
|
||||
fields = described_class.call(template, config, documents)
|
||||
|
||||
expect(fields.length).to eq(1)
|
||||
expect(fields.first['submitter_uuid']).to eq(template.submitters.first['uuid'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no attachment' do
|
||||
it 'returns empty array' do
|
||||
empty_template = create(:template, account: user.account, author: user, attachment_count: 0)
|
||||
|
||||
config = { 'submitters' => [], 'fields' => [] }
|
||||
result = described_class.call(empty_template, config)
|
||||
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple submitters' do
|
||||
let(:config) do
|
||||
{
|
||||
'submitters' => [{ 'name' => 'seller' }, { 'name' => 'buyer' }],
|
||||
'fields' => [
|
||||
{
|
||||
'name' => 'seller-sig',
|
||||
'type' => 'signature',
|
||||
'submitter' => 'seller',
|
||||
'position' => { 'page' => 0, 'x' => 0.1, 'y' => 0.5, 'w' => 0.2, 'h' => 0.05 }
|
||||
},
|
||||
{
|
||||
'name' => 'buyer-sig',
|
||||
'type' => 'signature',
|
||||
'submitter' => 'buyer',
|
||||
'position' => { 'page' => 0, 'x' => 0.5, 'y' => 0.5, 'w' => 0.2, 'h' => 0.05 }
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'assigns correct submitter_uuid to each field' do
|
||||
fields = described_class.call(template, config, documents)
|
||||
|
||||
seller = template.submitters.find { |s| s['name'].downcase == 'seller' }
|
||||
buyer = template.submitters.find { |s| s['name'].downcase == 'buyer' }
|
||||
|
||||
expect(fields.length).to eq(2)
|
||||
expect(fields[0]['submitter_uuid']).to eq(seller['uuid'])
|
||||
expect(fields[1]['submitter_uuid']).to eq(buyer['uuid'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with out-of-range page index' do
|
||||
let(:config) do
|
||||
{
|
||||
'submitters' => [{ 'name' => 'signer' }],
|
||||
'fields' => [
|
||||
{
|
||||
'name' => 'impossible-field',
|
||||
'type' => 'text',
|
||||
'submitter' => 'signer',
|
||||
'position' => { 'page' => 999, 'x' => 0.1, 'y' => 0.2, 'w' => 0.3, 'h' => 0.04 }
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'skips fields with invalid page index' do
|
||||
fields = described_class.call(template, config, documents)
|
||||
|
||||
expect(fields).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.resolve_page_index' do
|
||||
it 'returns page as-is for positive indices' do
|
||||
expect(described_class.resolve_page_index(0, 5)).to eq(0)
|
||||
expect(described_class.resolve_page_index(2, 5)).to eq(2)
|
||||
end
|
||||
|
||||
it 'resolves negative indices from end' do
|
||||
expect(described_class.resolve_page_index(-1, 5)).to eq(4)
|
||||
expect(described_class.resolve_page_index(-2, 3)).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns nil for zero total pages' do
|
||||
expect(described_class.resolve_page_index(0, 0)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '.clamp' do
|
||||
it 'clamps values between 0.0 and 1.0' do
|
||||
expect(described_class.clamp(-0.5)).to eq(0.0)
|
||||
expect(described_class.clamp(0.5)).to eq(0.5)
|
||||
expect(described_class.clamp(1.5)).to eq(1.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.clamp_dimension' do
|
||||
it 'enforces minimum dimension of 0.001' do
|
||||
expect(described_class.clamp_dimension(0.0)).to eq(0.001)
|
||||
expect(described_class.clamp_dimension(0.5)).to eq(0.5)
|
||||
expect(described_class.clamp_dimension(1.5)).to eq(1.0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,183 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require 'templates/field_detection'
|
||||
require 'templates/field_detection/config_based'
|
||||
|
||||
RSpec.describe Templates::FieldDetection do
|
||||
before { described_class.reset! }
|
||||
|
||||
after { described_class.reset! }
|
||||
|
||||
describe '.register' do
|
||||
it 'stores a handler by downcased name' do
|
||||
handler = ->(_template, _documents) { [] }
|
||||
described_class.register('MyAlgo', handler)
|
||||
|
||||
expect(described_class.registered_scripts).to include('myalgo' => handler)
|
||||
end
|
||||
|
||||
it 'strips whitespace from name' do
|
||||
handler = ->(_template, _documents) { [] }
|
||||
described_class.register(' spaced ', handler)
|
||||
|
||||
expect(described_class.registered_scripts).to include('spaced' => handler)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.available_algorithms' do
|
||||
it 'returns registered scripts merged with external YAML profiles' do
|
||||
handler = ->(_template, _documents) { [] }
|
||||
described_class.register('script_algo', handler)
|
||||
|
||||
allow(described_class).to receive(:external_algorithms).and_return(
|
||||
'yaml_profile' => { 'fields' => [] }
|
||||
)
|
||||
|
||||
algos = described_class.available_algorithms
|
||||
expect(algos).to include('script_algo' => handler)
|
||||
expect(algos).to include('yaml_profile' => { 'fields' => [] })
|
||||
end
|
||||
end
|
||||
|
||||
describe '.profile_names' do
|
||||
it 'returns sorted list of all algorithm names' do
|
||||
described_class.register('zebra', ->(_t, _d) { [] })
|
||||
described_class.register('alpha', ->(_t, _d) { [] })
|
||||
|
||||
allow(described_class).to receive(:external_algorithms).and_return(
|
||||
'middle' => { 'fields' => [] }
|
||||
)
|
||||
|
||||
expect(described_class.profile_names).to eq(%w[alpha middle zebra])
|
||||
end
|
||||
end
|
||||
|
||||
describe '.load_scripts!' do
|
||||
it 'loads .rb files from SCRIPTS_DIR' do
|
||||
Dir.mktmpdir do |dir|
|
||||
File.write(File.join(dir, 'test_algo.rb'), <<~RUBY)
|
||||
Templates::FieldDetection.register('test_algo', ->(_t, _d) { :loaded })
|
||||
RUBY
|
||||
|
||||
stub_const('Templates::FieldDetection::SCRIPTS_DIR', dir)
|
||||
described_class.reset!
|
||||
described_class.load_scripts!
|
||||
|
||||
expect(described_class.registered_scripts).to include('test_algo')
|
||||
expect(described_class.registered_scripts['test_algo'].call(nil, nil)).to eq(:loaded)
|
||||
end
|
||||
end
|
||||
|
||||
it 'logs warning and continues on script error' do
|
||||
Dir.mktmpdir do |dir|
|
||||
File.write(File.join(dir, 'bad_script.rb'), 'raise "boom"')
|
||||
File.write(File.join(dir, 'good_script.rb'), <<~RUBY)
|
||||
Templates::FieldDetection.register('good', ->(_t, _d) { :ok })
|
||||
RUBY
|
||||
|
||||
stub_const('Templates::FieldDetection::SCRIPTS_DIR', dir)
|
||||
described_class.reset!
|
||||
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.load_scripts!
|
||||
|
||||
expect(Rails.logger).to have_received(:warn).with(/Failed to load script.*bad_script\.rb.*boom/)
|
||||
expect(described_class.registered_scripts).to include('good')
|
||||
expect(described_class.registered_scripts).not_to include('bad_script')
|
||||
end
|
||||
end
|
||||
|
||||
it 'skips loading when SCRIPTS_DIR does not exist' do
|
||||
stub_const('Templates::FieldDetection::SCRIPTS_DIR', '/nonexistent/path')
|
||||
described_class.reset!
|
||||
|
||||
expect { described_class.load_scripts! }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe '.external_algorithms' do
|
||||
it 'parses YAML files from CONFIG_DIR' do
|
||||
Dir.mktmpdir do |dir|
|
||||
File.write(File.join(dir, 'profile_x.yml'), <<~YAML)
|
||||
submitters:
|
||||
- name: signer
|
||||
fields:
|
||||
- name: sig
|
||||
type: signature
|
||||
YAML
|
||||
|
||||
stub_const('Templates::FieldDetection::CONFIG_DIR', dir)
|
||||
|
||||
algos = described_class.external_algorithms
|
||||
expect(algos).to include('profile_x')
|
||||
expect(algos['profile_x']).to be_a(Hash)
|
||||
expect(algos['profile_x']['fields'].first['name']).to eq('sig')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns empty hash when CONFIG_DIR does not exist' do
|
||||
stub_const('Templates::FieldDetection::CONFIG_DIR', '/nonexistent/path')
|
||||
|
||||
expect(described_class.external_algorithms).to eq({})
|
||||
end
|
||||
|
||||
it 'skips malformed YAML files' do
|
||||
Dir.mktmpdir do |dir|
|
||||
File.write(File.join(dir, 'bad.yml'), ': invalid: yaml: [')
|
||||
File.write(File.join(dir, 'good.yml'), "submitters:\n - name: s\n")
|
||||
|
||||
stub_const('Templates::FieldDetection::CONFIG_DIR', dir)
|
||||
|
||||
algos = described_class.external_algorithms
|
||||
expect(algos).to include('good')
|
||||
expect(algos).not_to include('bad')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.call' do
|
||||
it 'raises ArgumentError for unknown algorithm' do
|
||||
expect { described_class.call(instance_double(Template), 'nonexistent') }
|
||||
.to raise_error(ArgumentError, /Unknown algorithm: 'nonexistent'/)
|
||||
end
|
||||
|
||||
it 'invokes registered script handler' do
|
||||
template = instance_double(Template)
|
||||
documents = instance_double(ActiveRecord::Relation)
|
||||
handler = ->(_t, _d) { :script_result }
|
||||
|
||||
described_class.register('my_script', handler)
|
||||
|
||||
result = described_class.call(template, 'my_script', documents)
|
||||
expect(result).to eq(:script_result)
|
||||
end
|
||||
|
||||
it 'dispatches YAML profile to ConfigBased.call' do
|
||||
template = instance_double(Template)
|
||||
documents = instance_double(ActiveRecord::Relation)
|
||||
config = { 'fields' => [], 'submitters' => [] }
|
||||
|
||||
allow(described_class).to receive(:external_algorithms).and_return(
|
||||
'yaml_test' => config
|
||||
)
|
||||
|
||||
allow(Templates::FieldDetection::ConfigBased).to receive(:call)
|
||||
.with(template, config, documents)
|
||||
.and_return([])
|
||||
|
||||
result = described_class.call(template, 'yaml_test', documents)
|
||||
expect(result).to eq([])
|
||||
expect(Templates::FieldDetection::ConfigBased).to have_received(:call)
|
||||
.with(template, config, documents)
|
||||
end
|
||||
|
||||
it 'is case-insensitive for algorithm names' do
|
||||
handler = ->(_t, _d) { :found }
|
||||
described_class.register('CaseSensitive', handler)
|
||||
|
||||
result = described_class.call(instance_double(Template), 'casesensitive')
|
||||
expect(result).to eq(:found)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue