bddgenx 2.4.6 → 2.4.9

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.
@@ -0,0 +1,133 @@
1
+ module Bddgenx
2
+ module IA
3
+ ##
4
+ # Cliente para interação com a API Microsoft Copilot para geração
5
+ # de conteúdo, aqui usado para criar cenários BDD no formato Gherkin.
6
+ #
7
+ class MicrosoftCopilotCliente
8
+ MICROSOFT_COPILOT_API_URL = ENV['MICROSOFT_COPILOT_API_URL']
9
+
10
+ ##
11
+ # Gera cenários BDD baseados em uma história, solicitando à API Microsoft Copilot
12
+ # o retorno no formato Gherkin com palavras-chave no idioma desejado.
13
+ #
14
+ # @param historia [String] Texto base da história para gerar os cenários.
15
+ # @param idioma [String] Código do idioma, 'pt' por padrão.
16
+ # @return [String, nil] Cenários no formato Gherkin, ou nil em caso de erro.
17
+ #
18
+ def self.gerar_cenarios(historia, idioma = 'pt')
19
+ api_key = Bddgenx.configuration.microsoft_copilot_api_key # para Copilot
20
+
21
+ # Define as palavras-chave para os cenários BDD
22
+ keywords_pt = {
23
+ feature: "Funcionalidade",
24
+ scenario: "Cenário",
25
+ scenario_outline: "Esquema do Cenário",
26
+ examples: "Exemplos",
27
+ given: "Dado",
28
+ when: "Quando",
29
+ then: "Então",
30
+ and: "E"
31
+ }
32
+
33
+ keywords_en = {
34
+ feature: "Feature",
35
+ scenario: "Scenario",
36
+ scenario_outline: "Scenario Outline",
37
+ examples: "Examples",
38
+ given: "Given",
39
+ when: "When",
40
+ then: "Then",
41
+ and: "And"
42
+ }
43
+
44
+ # Escolhe o conjunto de palavras-chave conforme o idioma
45
+ keywords = idioma == 'en' ? keywords_en : keywords_pt
46
+
47
+ # Prompt base que instrui a IA a gerar cenários Gherkin no idioma indicado
48
+ prompt_base = <<~PROMPT
49
+ Gere cenários BDD no formato Gherkin, utilizando as palavras-chave estruturais no idioma "#{idioma}":
50
+ Feature: #{keywords[:feature]}
51
+ Scenario: #{keywords[:scenario]}
52
+ Scenario Outline: #{keywords[:scenario_outline]}
53
+ Examples: #{keywords[:examples]}
54
+ Given: #{keywords[:given]}
55
+ When: #{keywords[:when]}
56
+ Then: #{keywords[:then]}
57
+ And: #{keywords[:and]}
58
+
59
+ Instruções:
60
+ - Todos os textos dos passos devem ser escritos em **português**.
61
+ - Use as palavras-chave Gherkin no idioma especificado ("#{idioma}").
62
+ - Gere **vários cenários**, incluindo positivos e negativos.
63
+ - Use `Scenario Outline` e `Examples` sempre que houver valores variáveis.
64
+ - Mantenha os parâmetros como `<email>`, `<senha>` e outros entre colchetes angulares, exatamente como aparecem.
65
+ - Se a história fornecer contexto (ex: `[CONTEXT]` ou "Dado que..."), utilize-o como base para os cenários.
66
+ - Se não houver contexto explícito, **crie um coerente** baseado na história.
67
+ - A primeira linha do resultado deve conter obrigatoriamente `# language: #{idioma}`.
68
+ - Evite passos vagos ou genéricos. Use ações claras e específicas.
69
+ - Gere apenas o conteúdo da feature, sem explicações adicionais.
70
+
71
+ História fornecida:
72
+ #{historia}
73
+ PROMPT
74
+
75
+ # Verifica se a chave de API foi configurada corretamente
76
+ unless api_key
77
+ warn "❌ API Key do Microsoft Copilot não encontrada no .env (MICROSOFT_COPILOT_API_KEY)"
78
+ return nil
79
+ end
80
+
81
+ # Define o endpoint da API Microsoft Copilot
82
+ uri = URI("#{MICROSOFT_COPILOT_API_URL}?key=#{api_key}")
83
+
84
+ # Estrutura do corpo da requisição para a API Microsoft Copilot
85
+ request_body = {
86
+ contents: [
87
+ {
88
+ model: "o4-mini",
89
+ role: "user",
90
+ parts: [{ text: prompt_base }]
91
+ }
92
+ ]
93
+ }
94
+
95
+ # Executa requisição POST para a API Microsoft Copilot
96
+ response = Net::HTTP.post(uri, request_body.to_json, { "Content-Type" => "application/json" })
97
+
98
+ # Verifica se a resposta foi bem-sucedida
99
+ if response.is_a?(Net::HTTPSuccess)
100
+ json = JSON.parse(response.body)
101
+
102
+ unless json["choices"]&.is_a?(Array) && json["choices"].any?
103
+ warn "❌ Resposta da IA sem candidatos válidos:"
104
+ warn JSON.pretty_generate(json)
105
+ return nil
106
+ end
107
+
108
+ # Recupera o conteúdo gerado pela IA
109
+ texto_ia = json["choices"].first.dig("message", "content")
110
+
111
+ if texto_ia
112
+ # Limpeza e sanitização do texto para manter padrão Gherkin
113
+ texto_limpo = Utils.limpar(texto_ia)
114
+ Utils.remover_steps_duplicados(texto_ia, idioma)
115
+
116
+ # Ajuste da diretiva de idioma na saída gerada
117
+ texto_limpo.sub!(/^# language: .*/, "# language: #{idioma}")
118
+ texto_limpo.prepend("# language: #{idioma}\n") unless texto_limpo.start_with?("# language:")
119
+
120
+ return texto_limpo
121
+ else
122
+ warn I18n.t('errors.ia_no_content')
123
+ warn JSON.pretty_generate(json)
124
+ return nil
125
+ end
126
+ else
127
+ warn I18n.t('errors.microsoft_copilot_error', code: response.code, body: response.body)
128
+ return nil
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -28,6 +28,7 @@ en:
28
28
  ia_no_content: "❌ No content returned from AI"
29
29
  gemini_error: "❌ Error calling Gemini: %{code} - %{body}"
30
30
  chatgpt_key_missing: "❌ ChatGPT API key not found in .env (OPENAI_API_KEY)"
31
+ microsoft_copilot_error: "Erro"
31
32
  openai_quota: "❌ OpenAI API quota exceeded."
32
33
  openai_check_usage: "🔗 Check your usage: https://platform.openai.com/account/usage"
33
34
  feature_not_found: "⚠️ Feature not found: %{feature}"
@@ -29,6 +29,7 @@ pt:
29
29
  gemini_error: "❌ Erro ao chamar Gemini: %{code} - %{body}"
30
30
  chatgpt_key_missing: "❌ API Key do ChatGPT não encontrada no .env (OPENAI_API_KEY)"
31
31
  openai_quota: "❌ Limite de uso da API OpenAI excedido."
32
+ microsoft_copilot_error: "Erro"
32
33
  openai_check_usage: "🔗 Verifique sua conta: https://platform.openai.com/account/usage"
33
34
  feature_not_found: "⚠️ Feature não encontrada: %{feature}"
34
35
  pdf_generation_failed: "❌ Erro ao gerar PDF de %{file}: %{error}"
@@ -1,70 +1,141 @@
1
- # lib/bddgenx/tracer.rb
2
1
  # encoding: utf-8
3
2
  #
4
- # Este arquivo define a classe Tracer, responsável por gerar e manter
5
- # informações de rastreabilidade de cenários e passos em um arquivo CSV.
6
- # Útil para auditoria e análise de cobertura de cenários gerados.
3
+ # Este arquivo define a classe `Tracer`, responsável por gerar arquivos de rastreabilidade
4
+ # (CSV) a partir das features geradas automaticamente pela gem BDDGenX.
5
+ #
6
+ # Para cada feature processada, o `Tracer` extrai os cenários da própria feature `.feature`
7
+ # e associa cada passo definido na história original com o bloco Gherkin correspondente.
8
+ # O objetivo é fornecer visibilidade e rastreabilidade completa entre requisitos e testes.
9
+
10
+ require 'csv'
11
+ require 'fileutils'
12
+
7
13
  module Bddgenx
8
- # Classe para adicionar registros de rastreabilidade a um relatório CSV.
14
+ # Classe responsável por rastrear os artefatos gerados pela gem
15
+ # e exportá-los em arquivos CSV, um por funcionalidade.
16
+ #
17
+ # Para cada grupo de passos (do `.txt`), associa os dados com o
18
+ # cenário equivalente gerado no arquivo `.feature`.
9
19
  class Tracer
10
- # Adiciona entradas de rastreabilidade para cada passo de cada grupo
11
- # da história em um arquivo CSV localizado em 'reports/output/rastreabilidade.csv'.
20
+ ##
21
+ # Adiciona entradas de rastreabilidade a um CSV baseado na feature gerada.
22
+ #
23
+ # - Cada funcionalidade recebe um arquivo CSV próprio, salvo em:
24
+ # `reports/output/funcionalidade_<nome>.csv`
25
+ #
26
+ # - A coluna "BDD" contém o cenário completo extraído diretamente do `.feature`,
27
+ # preservando a sintaxe original do Gherkin (cenário, steps, tags).
12
28
  #
13
29
  # @param historia [Hash]
14
- # Objeto de história contendo :quero (título da funcionalidade) e :grupos,
15
- # onde cada grupo possui :tipo, :tag, e :passos (Array<String>)
16
- # @param nome_arquivo_feature [String]
17
- # Nome do arquivo .feature de onde os passos foram gerados
30
+ # Hash representando a história extraída do `.txt`, contendo:
31
+ # - :quero → nome da funcionalidade
32
+ # - :grupos lista de blocos com :tipo, :tag e :passos
33
+ #
34
+ # @param feature_path [String]
35
+ # Caminho do arquivo `.feature` já gerado no sistema
36
+ #
18
37
  # @return [void]
19
- def self.adicionar_entrada(historia, nome_arquivo_feature)
20
- # Garante existência do diretório de saída
38
+ def self.adicionar_entrada(historia, feature_path)
21
39
  FileUtils.mkdir_p('reports/output')
22
- arquivo_csv = 'reports/output/rastreabilidade.csv'
23
40
 
24
- # Cabeçalho padrão do CSV: identifica colunas
25
- cabecalho = ['Funcionalidade', 'Tipo', 'Tag', 'Cenário', 'Passo', 'Origem']
41
+ nome_funcionalidade = historia[:quero].gsub(/^Quero\s*/, '').strip
42
+ nome_funcionalidade_sanitizado = nome_funcionalidade.downcase.gsub(/[^a-z0-9]+/, '_')
43
+ arquivo_csv = "reports/output/funcionalidade_#{nome_funcionalidade_sanitizado}.csv"
26
44
 
45
+ cabecalho = ['Funcionalidade', 'Tipo', 'Tag', 'Cenário', 'Passo', 'Origem', 'BDD']
27
46
  linhas = []
28
47
 
29
- # Itera sobre grupos de passos para compor linhas de rastreabilidade
48
+ # Leitura real da feature gerada
49
+ blocos_gherkin = extrair_cenarios_gherkin(feature_path)
50
+
30
51
  historia[:grupos].each_with_index do |grupo, idx|
31
52
  tipo = grupo[:tipo]
32
- tag = grupo[:tag]
53
+ tag = grupo[:tag] || '-'
33
54
  passos = grupo[:passos] || []
34
-
35
- nome_funcionalidade = historia[:quero].gsub(/^Quero\s*/, '').strip
36
55
  nome_cenario = "Cenário #{idx + 1}"
37
56
 
57
+ # Bloco Gherkin real do cenário gerado
58
+ gherkin_bloco = blocos_gherkin[idx] || ''
59
+
38
60
  passos.each do |passo|
39
61
  linhas << [
40
62
  nome_funcionalidade,
41
63
  tipo,
42
- tag || '-',
64
+ tag,
43
65
  nome_cenario,
44
66
  passo,
45
- File.basename(nome_arquivo_feature)
67
+ File.basename(feature_path),
68
+ gherkin_bloco
46
69
  ]
47
70
  end
48
71
  end
49
72
 
50
- # Escreve ou anexa as linhas geradas ao CSV
51
73
  escrever_csv(arquivo_csv, cabecalho, linhas)
52
74
  end
53
75
 
54
- # Escreve ou anexa registros em um arquivo CSV, criando cabeçalho se necessário.
76
+ ##
77
+ # Escreve ou anexa dados em um arquivo CSV.
78
+ # - Cria o cabeçalho caso seja a primeira escrita.
79
+ # - Evita duplicações com base na combinação "Passo + Origem".
80
+ #
81
+ # @param caminho [String] Caminho completo do arquivo CSV a ser salvo
82
+ # @param cabecalho [Array<String>] Títulos das colunas do CSV
83
+ # @param novas_linhas [Array<Array>] Linhas de conteúdo a serem gravadas
55
84
  #
56
- # @param caminho [String] Caminho completo para o arquivo CSV de rastreabilidade
57
- # @param cabecalho [Array<String>] Array de títulos das colunas a serem escritos
58
- # @param linhas [Array<Array<String>>] Dados a serem gravados no CSV (cada sub-array é uma linha)
59
85
  # @return [void]
60
- def self.escrever_csv(caminho, cabecalho, linhas)
61
- # Verifica se é um novo arquivo para incluir o cabeçalho
86
+ def self.escrever_csv(caminho, cabecalho, novas_linhas)
62
87
  novo_arquivo = !File.exist?(caminho)
63
88
 
89
+ existentes = []
90
+ if File.exist?(caminho)
91
+ existentes = CSV.read(caminho, col_sep: ';', headers: true).map do |row|
92
+ [row['Passo'], row['Origem']]
93
+ end
94
+ end
95
+
64
96
  CSV.open(caminho, 'a+', col_sep: ';', force_quotes: true) do |csv|
65
97
  csv << cabecalho if novo_arquivo
66
- linhas.each { |linha| csv << linha }
98
+
99
+ novas_linhas.each do |linha|
100
+ passo, origem = linha[4], linha[5]
101
+ next if existentes.include?([passo, origem])
102
+ csv << linha
103
+ end
67
104
  end
68
105
  end
106
+
107
+ ##
108
+ # Extrai todos os cenários completos do arquivo `.feature` gerado,
109
+ # preservando a estrutura Gherkin original (cenários, tags, steps).
110
+ #
111
+ # Um novo bloco é iniciado quando uma das palavras-chave de título
112
+ # de cenário é encontrada.
113
+ #
114
+ # @param feature_path [String] Caminho completo do arquivo `.feature`
115
+ # @return [Array<String>] Lista de blocos Gherkin, um por cenário
116
+ def self.extrair_cenarios_gherkin(feature_path)
117
+ return [] unless File.exist?(feature_path)
118
+
119
+ content = File.read(feature_path)
120
+ linhas = content.lines
121
+
122
+ blocos = []
123
+ bloco_atual = []
124
+ capturando = false
125
+
126
+ linhas.each_with_index do |linha, i|
127
+ if linha.strip =~ /^(Scenario|Scenario Outline|Cenário|Esquema do Cenário):/i
128
+ # Novo cenário → salva anterior
129
+ blocos << bloco_atual.join if bloco_atual.any?
130
+ bloco_atual = [linha]
131
+ capturando = true
132
+ elsif capturando
133
+ bloco_atual << linha
134
+ end
135
+ end
136
+
137
+ blocos << bloco_atual.join if bloco_atual.any?
138
+ blocos
139
+ end
69
140
  end
70
141
  end
@@ -0,0 +1,108 @@
1
+ # lib/bddgenx/properties_loader.rb
2
+ #
3
+ # Módulo `Bddgenx::PropertiesLoader` é responsável por carregar e processar os arquivos de configuração
4
+ # `.properties` que contêm variáveis de ambiente, além de também carregar as variáveis do arquivo `.env`.
5
+ # Este módulo lida com a substituição de placeholders nas propriedades, garantindo que as variáveis de ambiente
6
+ # sejam corretamente carregadas e definidas para uso no sistema.
7
+ #
8
+ # O fluxo de trabalho é o seguinte:
9
+ # 1. Carregar variáveis de ambiente a partir do arquivo `.env`.
10
+ # 2. Localizar e ler arquivos `.properties` presentes no diretório raiz do projeto.
11
+ # 3. Substituir placeholders no conteúdo dos arquivos `.properties` com variáveis de ambiente.
12
+ # 4. Mesclar as propriedades carregadas e definir as variáveis de ambiente no Ruby.
13
+ #
14
+ # Este módulo permite a configuração flexível de variáveis de ambiente, com suporte tanto para arquivos `.env`
15
+ # quanto para arquivos `.properties`.
16
+
17
+ module Bddgenx
18
+ class PropertiesLoader
19
+ # Carregar as variáveis do arquivo .env
20
+ #
21
+ # Este método utiliza a gem `dotenv` para carregar variáveis de ambiente a partir de um arquivo `.env`.
22
+ # Ele carrega as variáveis do arquivo `.env` para o ambiente de execução, onde elas ficam disponíveis via
23
+ # `ENV['VAR_NAME']` em qualquer parte do código.
24
+ def self.load_env_variables
25
+ Dotenv.load # Carrega as variáveis do .env automaticamente
26
+ end
27
+
28
+ # Função para substituir variáveis no conteúdo do arquivo .properties
29
+ #
30
+ # Este método recebe o conteúdo de um arquivo `.properties` e substitui os placeholders no formato `{{VAR_NAME}}`
31
+ # pelas variáveis de ambiente correspondentes, se definidas. Caso a variável de ambiente não esteja definida,
32
+ # o placeholder original é mantido no conteúdo.
33
+ #
34
+ # @param content [String] O conteúdo do arquivo `.properties` a ser processado.
35
+ # @return [String] O conteúdo com os placeholders substituídos pelas variáveis de ambiente.
36
+ def self.replace_placeholders(content)
37
+ content.gsub!(/\{\{(\w+)\}\}/) do |match|
38
+ ENV[$1] || match # Substitui pela variável de ambiente ou mantém o placeholder
39
+ end
40
+ content
41
+ end
42
+
43
+ # Função para garantir que o arquivo seja lido com a codificação correta
44
+ #
45
+ # Este método lê um arquivo especificado com codificação UTF-8. Caso o arquivo contenha caracteres inválidos,
46
+ # eles são substituídos por um caractere de substituição, garantindo que o conteúdo seja lido corretamente.
47
+ #
48
+ # @param file [String] O caminho do arquivo a ser lido.
49
+ # @return [String] O conteúdo do arquivo lido, com caracteres inválidos substituídos, se necessário.
50
+ def self.read_file_with_correct_encoding(file)
51
+ # Lê o arquivo com codificação UTF-8 e ignora caracteres inválidos
52
+ content = File.read(file, encoding: 'UTF-8')
53
+ content.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
54
+ end
55
+
56
+ # Carregar e substituir propriedades de arquivos .properties
57
+ #
58
+ # Este método localiza todos os arquivos `.properties` no diretório raiz do projeto,
59
+ # lê seu conteúdo, substitui os placeholders pelas variáveis de ambiente, carrega as propriedades
60
+ # e mescla essas propriedades em um único hash.
61
+ #
62
+ # Após carregar as propriedades, ele também define as variáveis de ambiente no Ruby (via `ENV`)
63
+ # usando as propriedades carregadas, mas não sobrescreve as variáveis de ambiente já definidas.
64
+ #
65
+ # @return [Hash] O hash contendo as propriedades carregadas e mescladas dos arquivos `.properties`.
66
+ def self.load_properties
67
+ # Carregar variáveis do .env primeiro
68
+ load_env_variables
69
+
70
+ # Localizar arquivos .properties na raiz do projeto
71
+ properties_files = Dir.glob(File.expand_path('../*.properties', __dir__))
72
+
73
+ properties = {}
74
+
75
+ properties_files.each do |file|
76
+ # Forçar a leitura do arquivo com codificação UTF-8 e lidar com caracteres inválidos
77
+ content = read_file_with_correct_encoding(file)
78
+
79
+ # Substituir os placeholders antes de carregar as propriedades
80
+ content = replace_placeholders(content)
81
+
82
+ # Carregar as propriedades do arquivo
83
+ file_properties = JavaProperties::Properties.load(StringIO.new(content))
84
+
85
+ # Mesclar as propriedades carregadas no hash
86
+ properties.merge!(file_properties.to_h)
87
+ end
88
+
89
+ # Agora, define as variáveis de ambiente a partir das propriedades carregadas
90
+ set_environment_variables(properties)
91
+
92
+ properties
93
+ end
94
+
95
+ # Função para definir variáveis de ambiente a partir das propriedades carregadas
96
+ #
97
+ # Este método percorre as propriedades carregadas e as define como variáveis de ambiente (`ENV`) no Ruby.
98
+ # Se a variável de ambiente já estiver definida (por exemplo, pelo `.env`), ela não será sobrescrita.
99
+ #
100
+ # @param properties [Hash] O hash contendo as propriedades carregadas dos arquivos `.properties`.
101
+ def self.set_environment_variables(properties)
102
+ properties.each do |key, value|
103
+ # Se a variável de ambiente já estiver definida, não sobrescreve
104
+ ENV[key.upcase] ||= value
105
+ end
106
+ end
107
+ end
108
+ end
@@ -1,5 +1,5 @@
1
1
  module Bddgenx
2
- class GherkinCleaner
2
+ module Utils
3
3
  # Método principal para limpar o texto Gherkin recebido.
4
4
  # Executa uma sequência de operações para deixar o texto formatado e correto.
5
5
  #
@@ -0,0 +1,45 @@
1
+ module Bddgenx
2
+ module Utils
3
+ # Palavras-chave do Gherkin em Português
4
+ GHERKIN_KEYS_PT = %w[Dado Quando Então E Mas].freeze
5
+
6
+ # Palavras-chave do Gherkin em Inglês
7
+ GHERKIN_KEYS_EN = %w[Given When Then And But].freeze
8
+
9
+ # Mapeamento PT → EN
10
+ GHERKIN_MAP_PT_EN = GHERKIN_KEYS_PT.zip(GHERKIN_KEYS_EN).to_h
11
+
12
+ # Mapeamento EN → PT
13
+ GHERKIN_MAP_EN_PT = GHERKIN_KEYS_EN.zip(GHERKIN_KEYS_PT).to_h
14
+
15
+ # Todas as palavras-chave reconhecidas
16
+ ALL_KEYS = GHERKIN_KEYS_PT + GHERKIN_KEYS_EN
17
+
18
+ ##
19
+ # Extrai o idioma do arquivo .txt, a partir da linha "# language:".
20
+ # @param txt_file [String] Caminho do arquivo .txt
21
+ # @return [String] O idioma extraído ou 'pt' como padrão
22
+ def self.obter_idioma_do_arquivo(caminho_arquivo)
23
+ return 'pt' unless File.exist?(caminho_arquivo)
24
+
25
+ File.foreach(caminho_arquivo) do |linha|
26
+ if linha =~ /^#\s*language:\s*(\w{2})/i
27
+ return $1.downcase
28
+ end
29
+ end
30
+
31
+ 'pt' # idioma padrão caso não encontre
32
+ end
33
+
34
+ ##
35
+ # Detecta o idioma a partir de um texto (como conteúdo de arquivo ou string).
36
+ # @param texto [String] O texto onde o idioma será detectado
37
+ # @return [String] O idioma detectado ('pt' por padrão)
38
+ def self.detecta_idioma_de_texto(texto)
39
+ if texto =~ /^#\s*language:\s*(\w{2})/i
40
+ return $1.downcase
41
+ end
42
+ 'pt' # Idioma padrão se o idioma não for detectado
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,79 @@
1
+ module Bddgenx
2
+ module Utils
3
+ # Remove passos duplicados em um texto de cenários BDD,
4
+ # levando em conta o idioma para identificar as keywords (Given, When, Then, And / Dado, Quando, Então, E)
5
+ #
6
+ # Parâmetros:
7
+ # - texto: string contendo o texto do cenário BDD
8
+ # - idioma: 'en' para inglês ou qualquer outro para português
9
+ #
10
+ # Retorna o texto com passos duplicados removidos, preservando a ordem original
11
+ def self.remover_steps_duplicados(texto, idioma)
12
+ # Define as keywords principais para o idioma
13
+ keywords = idioma == 'en' ? %w[Given When Then And] : %w[Dado Quando Então E]
14
+
15
+ # Conjunto para rastrear passos já vistos (versão canônica)
16
+ seen = Set.new
17
+ resultado = []
18
+
19
+ # Percorre linha a linha
20
+ texto.each_line do |linha|
21
+ # Verifica se a linha começa com uma das keywords
22
+ if keywords.any? { |kw| linha.strip.start_with?(kw) }
23
+ # Canonicaliza o passo para comparação sem variações irrelevantes
24
+ canonical = Utils::canonicalize_step(linha, keywords)
25
+
26
+ # Só adiciona se ainda não viu o passo canônico
27
+ unless seen.include?(canonical)
28
+ seen.add(canonical)
29
+ resultado << linha
30
+ end
31
+ else
32
+ # Linhas que não são passos são adicionadas normalmente
33
+ resultado << linha
34
+ end
35
+ end
36
+
37
+ # Retorna o texto reconstruído sem duplicatas
38
+ resultado.join
39
+ end
40
+
41
+ # Gera uma versão canônica (normalizada) do passo para facilitar
42
+ # a identificação de duplicatas mesmo com variações menores de texto.
43
+ #
44
+ # Exemplo: Dado "usuario" fez login e Dado <usuario> fez login
45
+ # gerarão o mesmo canonical para evitar repetição.
46
+ #
47
+ # Passos:
48
+ # - Remove a keyword (Given, When, etc) do começo
49
+ # - Substitui textos entre aspas, placeholders <> e números por <param>
50
+ # - Remove acentuação e pontuação para normalizar
51
+ # - Converte para minúsculas e remove espaços extras
52
+ #
53
+ # Parâmetros:
54
+ # - linha: string com o passo completo
55
+ # - keywords: array com as keywords para remoção
56
+ #
57
+ # Retorna uma string normalizada representando o passo
58
+ def self.canonicalize_step(linha, keywords)
59
+ texto = linha.dup.strip
60
+
61
+ # Remove a keyword do início, se existir
62
+ keywords.each do |kw|
63
+ texto.sub!(/^#{kw}\s+/i, '')
64
+ end
65
+
66
+ # Substitui textos entre aspas, placeholders e números por <param>
67
+ texto.gsub!(/"[^"]*"|<[^>]*>|\b\d+\b/, '<param>')
68
+
69
+ # Remove acentos usando Unicode Normalization Form KD (decompõe caracteres)
70
+ texto = Unicode.normalize_KD(texto).gsub(/\p{Mn}/, '')
71
+
72
+ # Remove pontuação, deixando apenas letras, números, espaços e <>
73
+ texto.gsub!(/[^a-zA-Z0-9\s<>]/, '')
74
+
75
+ # Converte para minúsculas, remove espaços extras e retorna
76
+ texto.downcase.strip.squeeze(" ")
77
+ end
78
+ end
79
+ end