feat: add pluggable field detection system (v0.14.0)

* 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
devin-ai-integration[bot] 2 weeks ago committed by GitHub
parent d08179854b
commit 76a60244e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6,6 +6,26 @@ class TemplatesDetectFieldsController < ApplicationController
load_and_authorize_resource :template
def create
if params[:algorithm].present?
create_with_algorithm
else
create_with_ml_detection
end
end
private
def create_with_algorithm
documents = @template.schema_documents.preload(:blob)
fields = Templates::FieldDetection.call(@template, params[:algorithm], documents)
render json: { fields: fields, submitters: @template.submitters, completed: true }
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_content
end
def create_with_ml_detection
response.headers['Content-Type'] = 'text/event-stream'
sse = SSE.new(response.stream)

@ -186,7 +186,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true'
showTourStartForm: this.dataset.showTourStartForm === 'true',
detectionAlgorithms: JSON.parse(this.dataset.detectionAlgorithms || '[]')
})
this.component = this.app.mount(this.appElem)

@ -509,6 +509,7 @@
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection"
:detection-algorithms="detectionAlgorithms"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields"
@ -738,6 +739,11 @@ export default {
required: false,
default: false
},
detectionAlgorithms: {
type: Array,
required: false,
default: () => []
},
withCustomFields: {
type: Boolean,
required: false,

@ -324,8 +324,90 @@
<div
v-if="!isShowVariables && withFieldsDetection && editable && fields.length < 2 && !template.schema.some((item) => item.dynamic)"
class="my-2"
>
<div
v-if="detectionAlgorithms.length"
class="flex"
>
<button
class="btn flex-1 !rounded-r-none"
:class="{ 'bg-base-300': fieldPagesLoaded !== null || isAlgorithmLoading }"
@click="fieldPagesLoaded !== null || isAlgorithmLoading ? null : detectFields()"
>
<template v-if="fieldPagesLoaded !== null">
<IconInnerShadowTop
width="22"
class="animate-spin"
/>
<span
v-if="analyzingProgress"
class="hidden md:inline"
>
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
</span>
<span
v-else
class="hidden md:inline"
>
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
</span>
</template>
<template v-else-if="isAlgorithmLoading">
<IconInnerShadowTop
width="22"
class="animate-spin"
/>
<span class="hidden md:inline">
{{ t('processing_') }}
</span>
</template>
<template v-else>
<IconSparkles width="22" />
<span class="hidden md:inline">
{{ t('autodetect_fields') }}
</span>
</template>
</button>
<div class="dropdown dropdown-end">
<label
tabindex="0"
class="btn !rounded-l-none !border-l-0 !px-2"
:class="{ 'bg-base-300': fieldPagesLoaded !== null || isAlgorithmLoading }"
>
<IconChevronDown class="w-4 h-4" />
</label>
<ul
tabindex="0"
class="dropdown-content z-30 p-2 mt-1 shadow menu bg-base-100 rounded-box w-52"
>
<li>
<a
href="#"
class="flex items-center space-x-2"
@click.prevent="detectFields()"
>
<IconSparkles class="w-4 h-4 flex-shrink-0" />
<span>{{ t('autodetect_fields') }}</span>
</a>
</li>
<li
v-for="algo in detectionAlgorithms"
:key="algo"
>
<a
href="#"
class="flex items-center space-x-2"
@click.prevent="detectFieldsWithAlgorithm(algo)"
>
<IconSparkles class="w-4 h-4 flex-shrink-0" />
<span>{{ algo }}</span>
</a>
</li>
</ul>
</div>
</div>
<button
v-else
class="btn w-full"
:class="{ 'bg-base-300': fieldPagesLoaded !== null }"
@click="fieldPagesLoaded !== null ? null : detectFields()"
@ -383,7 +465,7 @@ import CustomField from './custom_field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { defineAsyncComponent } from 'vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain, IconChevronDown } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
import { v4 } from 'uuid'
@ -400,6 +482,7 @@ export default {
IconDrag,
IconLock,
IconBracketsContain,
IconChevronDown,
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables'))
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
@ -428,6 +511,11 @@ export default {
required: false,
default: false
},
detectionAlgorithms: {
type: Array,
required: false,
default: () => []
},
withSignatureId: {
type: Boolean,
required: false,
@ -510,7 +598,8 @@ export default {
showCustomTab: false,
defaultFieldsSearch: '',
customFieldsSearch: '',
isShowVariables: false
isShowVariables: false,
isAlgorithmLoading: false
}
},
computed: {
@ -734,6 +823,34 @@ export default {
this.isFieldsLoading = false
})
},
detectFieldsWithAlgorithm (algorithm) {
this.isAlgorithmLoading = true
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
method: 'POST',
body: JSON.stringify({ algorithm }),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
const data = await resp.json()
if (data.error) {
alert(data.error)
} else if (data.fields) {
this.template.fields = data.fields
if (data.submitters) {
this.template.submitters = data.submitters
this.$emit('select-submitter', this.template.submitters[0])
}
this.save()
}
}).catch(error => {
console.error('Error detecting fields with algorithm: ', error)
}).finally(() => {
this.isAlgorithmLoading = false
})
},
setDragPlaceholder (event) {
this.$emit('set-drag-placeholder', {
offsetX: event.offsetX,

@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %>
<% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-detection-algorithms="<%= Templates::FieldDetection.profile_names.to_json %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>

@ -4,6 +4,8 @@
# Currently exposes SMTP settings; additional external config concerns can be
# added here as needed.
module ExternalConfig
CONFIG_DIR = ENV.fetch('DOCUSEAL_CONFIG_DIR', '/etc/docuseal')
SMTP_ENV_KEYS = {
address: 'DOCUSEAL_CONFIG_SMTP_ADDRESS',
port: 'DOCUSEAL_CONFIG_SMTP_PORT',

@ -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…
Cancel
Save