Packages

A declarative text parsing library for Elixir

Current section

Files

Jump to
parselet test component_test.exs
Raw

test/component_test.exs

defmodule Parselet.ComponentTest do
use ExUnit.Case, async: true
describe "basic field definition" do
defmodule BasicComponent do
use Parselet.Component
field :name, pattern: ~r/Name:\s*(.+)/
field :age, pattern: ~r/Age:\s*(\d+)/, capture: :first, transform: &String.to_integer/1
end
test "extracts fields using patterns" do
text = "Name: Alice\nAge: 30"
result = Parselet.parse(text, components: [BasicComponent])
assert result.name == "Alice"
assert result.age == 30
end
test "returns empty map when no fields match" do
text = "No data here"
result = Parselet.parse(text, components: [BasicComponent])
assert result == %{}
end
test "includes only matched fields" do
text = "Name: Bob"
result = Parselet.parse(text, components: [BasicComponent])
assert Map.has_key?(result, :name)
refute Map.has_key?(result, :age)
end
test "accesses __parselet_fields__ function" do
fields = BasicComponent.__parselet_fields__()
assert Map.has_key?(fields, :name)
assert Map.has_key?(fields, :age)
assert is_struct(fields.name, Parselet.Field)
assert is_struct(fields.age, Parselet.Field)
end
defmodule ParseHelperStrictComponent do
use Parselet.Component
field :id, pattern: ~r/ID:\s*(\d+)/, required: true
end
test "component module parse/2 returns struct-like result" do
text = "Name: Alice\nAge: 30"
result = BasicComponent.parse(text)
assert result.__struct__ == BasicComponent
assert result.name == "Alice"
assert result.age == 30
end
test "component module parse/2 returns error tuple on missing required fields" do
result = ParseHelperStrictComponent.parse("Name: Alice")
assert result == {:error, %{reason: "Missing required fields", fields: [:id]}}
end
test "component module parse!/2 raises on missing required fields" do
assert_raise ArgumentError, fn ->
ParseHelperStrictComponent.parse!("Name: Alice")
end
end
test "component module parse!/2 returns struct on valid input" do
result = ParseHelperStrictComponent.parse!("ID: 123")
assert result.__struct__ == ParseHelperStrictComponent
assert result.id == "123"
end
end
describe "pattern variations" do
defmodule PatternComponent do
use Parselet.Component
field :email, pattern: ~r/Email:\s*(\S+@\S+)/
field :code, pattern: ~r/CODE:\s*([A-Z0-9]+)/i
field :value, pattern: ~r/(?:Value|Amount):\s*(.+)/
end
test "captures with first modifier" do
text = "Email: user@example.com"
result = Parselet.parse(text, components: [PatternComponent])
assert result.email == "user@example.com"
end
test "case insensitive matching" do
text1 = "code: ABC123"
text2 = "CODE: ABC123"
text3 = "Code: ABC123"
result1 = Parselet.parse(text1, components: [PatternComponent])
result2 = Parselet.parse(text2, components: [PatternComponent])
result3 = Parselet.parse(text3, components: [PatternComponent])
assert result1.code == "ABC123"
assert result2.code == "ABC123"
assert result3.code == "ABC123"
end
test "alternation in patterns" do
text1 = "Value: 100"
text2 = "Amount: 200"
result1 = Parselet.parse(text1, components: [PatternComponent])
result2 = Parselet.parse(text2, components: [PatternComponent])
assert result1.value == "100"
assert result2.value == "200"
end
end
describe "transform functions" do
defmodule TransformComponent do
use Parselet.Component
field :trimmed,
pattern: ~r/Name:\s*(.+)/,
transform: &String.trim/1
field :uppercase,
pattern: ~r/City:\s*(.+)/,
transform: &String.upcase/1
field :number,
pattern: ~r/Count:\s*(\d+)/,
transform: &String.to_integer/1
field :amount,
pattern: ~r/Price:\s*\$?([\d,]+\.\d+)/,
transform: fn s -> s |> String.replace(",", "") |> String.to_float() end
end
test "string trim transformation" do
text = "Name: Alice "
result = Parselet.parse(text, components: [TransformComponent])
assert result.trimmed == "Alice"
end
test "uppercase transformation" do
text = "City: new york"
result = Parselet.parse(text, components: [TransformComponent])
assert result.uppercase == "NEW YORK"
end
test "integer conversion" do
text = "Count: 42"
result = Parselet.parse(text, components: [TransformComponent])
assert result.number == 42
assert is_integer(result.number)
end
test "currency formatting" do
text = "Price: $1,234.56"
result = Parselet.parse(text, components: [TransformComponent])
assert result.amount == 1234.56
assert is_float(result.amount)
end
end
describe "custom function extractors" do
defmodule FunctionComponent do
use Parselet.Component
field :first_line,
function: fn text ->
text |> String.split("\n") |> List.first()
end
field :line_count,
function: fn text ->
text |> String.split("\n") |> length()
end
field :summary,
function: fn text ->
case String.split(text, "---") do
[_header, summary, _footer] -> String.trim(summary)
_ -> nil
end
end
field :has_error,
function: fn text ->
String.contains?(text, "ERROR")
end
end
test "extracts first line" do
text = "Line 1\nLine 2\nLine 3"
result = Parselet.parse(text, components: [FunctionComponent])
assert result.first_line == "Line 1"
end
test "counts lines" do
text = "Line 1\nLine 2\nLine 3"
result = Parselet.parse(text, components: [FunctionComponent])
assert result.line_count == 3
end
test "extracts section between delimiters" do
text = "Header\n---\nMain content\n---\nFooter"
result = Parselet.parse(text, components: [FunctionComponent])
assert result.summary == "Main content"
end
test "returns nil when delimiter not found" do
text = "Single line"
result = Parselet.parse(text, components: [FunctionComponent])
assert Map.has_key?(result, :summary) == false
end
test "boolean extraction" do
text1 = "Status: ERROR in processing"
text2 = "Status: OK"
result1 = Parselet.parse(text1, components: [FunctionComponent])
result2 = Parselet.parse(text2, components: [FunctionComponent])
assert result1.has_error == true
assert result2.has_error == false
end
end
describe "preprocess" do
defmodule PreprocessDirectComponent do
use Parselet.Component
preprocess &String.upcase/1
field :name, pattern: ~r/NAME:\s*(.+)/
end
defmodule PreprocessInlineComponent do
use Parselet.Component
preprocess fn text ->
String.upcase(text)
end
field :name, pattern: ~r/NAME:\s*(.+)/
end
test "applies preprocess before field extraction with direct syntax" do
text = "Name: Bob"
result = Parselet.parse(text, components: [PreprocessDirectComponent])
assert result.name == "BOB"
end
test "applies preprocess before field extraction with inline function syntax" do
text = "Name: Carol"
result = Parselet.parse(text, components: [PreprocessInlineComponent])
assert result.name == "CAROL"
end
test "preprocess keyword syntax is rejected at compile time" do
assert_raise ArgumentError, ~r/preprocess keyword syntax is no longer supported/, fn ->
Code.compile_string(~S"
defmodule PreprocessKeywordRejectComponent do
use Parselet.Component
preprocess function: &String.upcase/1
field :name, pattern: ~r/NAME:\s*(.+)/
end
")
end
end
end
describe "postprocess functions" do
defmodule PostprocessMapComponent do
use Parselet.Component
field :first_name, pattern: ~r/First:\s*(\w+)/, capture: :first
field :last_name, pattern: ~r/Last:\s*(\w+)/, capture: :first
postprocess fn fields ->
case Map.take(fields, [:first_name, :last_name]) do
%{first_name: first, last_name: last} -> %{full_name: "#{first} #{last}"}
_ -> :ok
end
end
end
defmodule PostprocessOkComponent do
use Parselet.Component
field :code, pattern: ~r/Code:\s*(\w+)/, capture: :first
postprocess fn _fields ->
:ok
end
end
defmodule PostprocessKeywordComponent do
use Parselet.Component
field :code, pattern: ~r/Code:\s*(\w+)/, capture: :first
postprocess function: fn _fields -> %{parsed_at: "now"} end
end
test "merges additional values returned by postprocess" do
text = "First: Alice\nLast: Smith"
result = Parselet.parse(text, components: [PostprocessMapComponent])
assert result.first_name == "Alice"
assert result.last_name == "Smith"
assert result.full_name == "Alice Smith"
end
test "leaves parsed values unchanged when postprocess returns :ok" do
text = "Code: AZ1"
result = Parselet.parse(text, components: [PostprocessOkComponent])
assert result.code == "AZ1"
assert Map.keys(result) == [:code]
end
defmodule PostprocessErrorComponent do
use Parselet.Component
field :status, pattern: ~r/Status:\s*(\w+)/, capture: :first
postprocess fn _fields ->
{:error, "invalid status"}
end
end
defmodule PostprocessErrorFieldsComponent do
use Parselet.Component
field :status, pattern: ~r/Status:\s*(\w+)/, capture: :first
postprocess fn _fields ->
{:error, %{reason: "status invalid", fields: [:status]}}
end
end
defmodule PostprocessNonStringReasonComponent do
use Parselet.Component
field :status, pattern: ~r/Status:\s*(\w+)/, capture: :first
postprocess fn _fields ->
{:error, :bad_reason}
end
end
test "returns error tuple when postprocess returns {:error, reason}" do
text = "Status: OK"
assert Parselet.parse(text, components: [PostprocessErrorComponent]) ==
{:error, %{reason: "invalid status", fields: []}}
end
test "returns error tuple when postprocess returns {:error, %{reason, fields}}" do
text = "Status: OK"
assert Parselet.parse(text, components: [PostprocessErrorFieldsComponent]) ==
{:error, %{reason: "status invalid", fields: [:status]}}
assert_raise ArgumentError, "status invalid: [:status]", fn ->
Parselet.parse!(text, components: [PostprocessErrorFieldsComponent])
end
end
test "returns error tuple when postprocess returns {:error, non-string reason}" do
text = "Status: OK"
assert Parselet.parse(text, components: [PostprocessNonStringReasonComponent]) ==
{:error, %{reason: ":bad_reason", fields: []}}
end
test "returns error tuple when postprocess fails for structs" do
text = "Status: OK"
assert Parselet.parse(text, structs: [PostprocessErrorComponent]) ==
{:error, %{reason: "invalid status", fields: []}}
end
test "returns error tuple when postprocess fails inside multiple structs" do
text = "First: Alice\nLast: Smith\nStatus: OK"
assert Parselet.parse(text, structs: [PostprocessMapComponent, PostprocessErrorComponent]) ==
{:error, %{reason: "invalid status", fields: []}}
end
test "postprocess keyword syntax works with function option" do
text = "Code: AZ1"
result = Parselet.parse(text, components: [PostprocessKeywordComponent])
assert result.code == "AZ1"
assert result.parsed_at == "now"
end
end
describe "required fields" do
defmodule StrictComponent do
use Parselet.Component
field :id, pattern: ~r/ID:\s*(\d+)/, required: true
field :name, pattern: ~r/Name:\s*(.+)/, required: true
field :email, pattern: ~r/Email:\s*(.+)/
end
test "parse with all required fields present" do
text = "ID: 123\nName: Alice\nEmail: alice@example.com"
result = Parselet.parse(text, components: [StrictComponent])
assert result.id == "123"
assert result.name == "Alice"
assert result.email == "alice@example.com"
end
test "returns error tuple when required field is missing" do
text = "ID: 123\nEmail: bob@example.com"
assert Parselet.parse(text, components: [StrictComponent]) ==
{:error, %{reason: "Missing required fields", fields: [:name]}}
end
test "parse! with all required fields present" do
text = "ID: 456\nName: Charlie"
result = Parselet.parse!(text, components: [StrictComponent])
assert result.id == "456"
assert result.name == "Charlie"
end
test "parse! raises when required field missing" do
text = "ID: 789"
assert_raise ArgumentError, "Missing required fields: [:name]", fn ->
Parselet.parse!(text, components: [StrictComponent])
end
end
test "parse! raises with multiple missing required fields" do
text = "Email: test@example.com"
assert_raise ArgumentError, fn ->
Parselet.parse!(text, components: [StrictComponent])
end
end
test "required false is default" do
defmodule OptionalComponent do
use Parselet.Component
field :optional_field, pattern: ~r/Data:\s*(.+)/
end
text = "No data"
result = Parselet.parse(text, components: [OptionalComponent])
assert result == %{}
end
end
describe "multiple components" do
defmodule Component1 do
use Parselet.Component
field :name, pattern: ~r/Name:\s*(.+)/
field :age, pattern: ~r/Age:\s*(\d+)/, transform: &String.to_integer/1
end
defmodule Component2 do
use Parselet.Component
field :email, pattern: ~r/Email:\s*(.+)/
field :phone, pattern: ~r/Phone:\s*(.+)/
end
test "merges results from multiple components" do
text = "Name: Alice\nAge: 30\nEmail: alice@example.com\nPhone: 555-1234"
result = Parselet.parse(text, components: [Component1, Component2])
assert result.name == "Alice"
assert result.age == 30
assert result.email == "alice@example.com"
assert result.phone == "555-1234"
end
test "handles overlapping field names" do
defmodule ComponentA do
use Parselet.Component
field :id, pattern: ~r/ID:\s*(\d+)/
end
defmodule ComponentB do
use Parselet.Component
field :id, pattern: ~r/Code:\s*([A-Z]+)/
end
text = "ID: 123\nCode: ABC"
result = Parselet.parse(text, components: [ComponentA, ComponentB])
assert result.id == "ABC"
end
test "parse! checks all components required fields" do
defmodule StrictA do
use Parselet.Component
field :required_a, pattern: ~r/A:\s*(.+)/, required: true
end
defmodule StrictB do
use Parselet.Component
field :required_b, pattern: ~r/B:\s*(.+)/, required: true
end
text = "A: Present"
assert_raise ArgumentError, "Missing required fields: [:required_b]", fn ->
Parselet.parse!(text, components: [StrictA, StrictB])
end
end
end
describe "edge cases" do
defmodule EdgeComponent do
use Parselet.Component
field :empty, pattern: ~r/Empty:\s*(.*)/
field :multiline, function: fn text -> String.split(text, "\n") end
end
test "handles empty captures" do
text = "Empty: "
result = Parselet.parse(text, components: [EdgeComponent])
assert result.empty == ""
end
test "handles multiline text in functions" do
text = "Line 1\nLine 2\nLine 3"
result = Parselet.parse(text, components: [EdgeComponent])
assert result.multiline == ["Line 1", "Line 2", "Line 3"]
end
test "handles special characters in patterns" do
defmodule SpecialComponent do
use Parselet.Component
field :path, pattern: ~r/Path:\s*(.+)/
field :url, pattern: ~r/URL:\s*(.+)/
end
text = "Path: /home/user/file.txt\nURL: https://example.com?id=123&name=test"
result = Parselet.parse(text, components: [SpecialComponent])
assert result.path == "/home/user/file.txt"
assert result.url == "https://example.com?id=123&name=test"
end
end
describe "field struct properties" do
test "field struct contains all properties" do
defmodule TestComponent do
use Parselet.Component
field :test, pattern: ~r/Test:\s*(.+)/, required: true
end
fields = TestComponent.__parselet_fields__()
field = fields.test
assert field.name == :test
assert field.pattern == ~r/Test:\s*(.+)/
assert field.capture == :first
assert field.required == true
assert is_function(field.transform)
end
test "field with all options" do
defmodule FullComponent do
use Parselet.Component
field :complex,
pattern: ~r/(\d+)-(\d+)/,
capture: :all,
transform: fn [a, b] -> String.to_integer(a) + String.to_integer(b) end,
required: false
end
fields = FullComponent.__parselet_fields__()
field = fields.complex
assert field.capture == :all
assert field.required == false
end
test "field with function instead of pattern" do
defmodule FunctionOnlyComponent do
use Parselet.Component
field :computed, function: fn _text -> 42 end
end
fields = FunctionOnlyComponent.__parselet_fields__()
field = fields.computed
assert field.function != nil
assert field.pattern == nil
end
test "field extract returns nil when no pattern or function is specified" do
field = Parselet.Field.new(:missing, [])
assert Parselet.Field.extract(field, "text") == nil
end
test "field extract supports capture :all and fallback nil" do
field = Parselet.Field.new(:date, pattern: ~r/(\d{4})-(\d{2})-(\d{2})/, capture: :all)
assert Parselet.Field.extract(field, "2026-03-29") == ["2026", "03", "29"]
assert Parselet.Field.extract(field, "no-match") == nil
end
test "validate_required/4 handles merge false nested results" do
fields_map = %{StructComponent => %{a: "1"}}
field_structs = %{
a: Parselet.Field.new(:a, required: true),
b: Parselet.Field.new(:b, required: true)
}
assert Parselet.Field.validate_required(fields_map, field_structs, false) == [:b]
end
test "validate_required/2 uses merged validation" do
fields_map = %{a: 1}
field_structs = %{a: Parselet.Field.new(:a, required: true)}
assert Parselet.Field.validate_required(fields_map, field_structs) == []
end
end
describe "extraction priority" do
defmodule PriorityComponent do
use Parselet.Component
field :priority,
pattern: ~r/Pattern:\s*(.+)/,
function: fn _text -> "function_result" end
end
test "function extraction takes priority over pattern" do
text = "Pattern: from_pattern"
result = Parselet.parse(text, components: [PriorityComponent])
assert result.priority == "function_result"
end
end
describe "real world scenarios" do
defmodule InvoiceComponent do
use Parselet.Component
field :invoice_id, pattern: ~r/Invoice #(\d+)/, required: true
field :amount,
pattern: ~r/Total:\s*\$?([\d,]+\.\d+)/,
transform: fn s -> s |> String.replace(",", "") |> String.to_float() end,
required: true
field :vendor, pattern: ~r/Vendor:\s*(.+)/
end
test "parses valid invoice" do
invoice = """
Invoice #12345
Vendor: ACME Corp
Total: $1,234.56
"""
result = Parselet.parse!(invoice, components: [InvoiceComponent])
assert result.invoice_id == "12345"
assert result.amount == 1234.56
assert result.vendor == "ACME Corp"
end
test "rejects invoice with missing required fields" do
incomplete = """
Invoice #999
Vendor: ACME Corp
"""
assert_raise ArgumentError, fn ->
Parselet.parse!(incomplete, components: [InvoiceComponent])
end
end
test "handles invoice with optional fields missing" do
minimal = """
Invoice #999
Total: $500.00
"""
result = Parselet.parse!(minimal, components: [InvoiceComponent])
assert result.invoice_id == "999"
assert result.amount == 500.0
assert !Map.has_key?(result, :vendor)
end
end
describe "airbnb reservation component" do
defmodule AirbnbReservationComponent do
use Parselet.Component
field :reservation_code, pattern: ~r/Reservation code:\s*([A-Z0-9]+)/
field :guest_name, pattern: ~r/(?:You're hosting|Reservation for)\s+(.+)/
field :check_in_date, pattern: ~r/(\w{3} \d{1,2}) – \w{3} \d{1,2}/, capture: :first
field :check_out_date, pattern: ~r/\w{3} \d{1,2} – (\w{3} \d{1,2})/, capture: :first
field :nights, pattern: ~r/· (\d+) nights/, capture: :first, transform: &String.to_integer/1
field :property_name,
function: fn text ->
lines =
text
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
date_line = ~r/[A-Za-z]{3}\s+\d{1,2}\s+[–—-]\s+[A-Za-z]{3}\s+\d{1,2}.*\b\d+\s+nights?/i
lines
|> case do
[] -> nil
ls ->
case Enum.find_index(ls, &String.match?(&1, date_line)) do
nil ->
ls
|> Enum.find(fn line ->
String.contains?(line, ["BR", "Apartment", "House", "Gateway"]) and
not String.match?(line, ~r/^(Reservation|Reservation code|Check-?in|Checkout|Your earnings|Payout|\d+\s+guests?)/i)
end)
idx ->
ls
|> Enum.drop(idx + 1)
|> Enum.find(fn line ->
line not in [""] and
not String.match?(line, ~r/^(\d+\s+guests?|Check-?in|Checkout|Your earnings|Payout)/i)
end)
end
end
end
field :guest_count, pattern: ~r/(\d+) guests/, capture: :first, transform: &String.to_integer/1
field :check_in_time, pattern: ~r/Check-in:\s*(.+)/
field :check_out_time, pattern: ~r/Checkout:\s*(.+)/
field :earnings, pattern: ~r/(?:Your earnings|Payout):\s*\$([\d,]+\.\d{2})/, capture: :first, transform: fn(amount) ->
amount |> String.replace(",", "") |> String.to_float()
end
end
test "parses airbnb reservation 1 fixture" do
fixture_path = Path.join([__DIR__, "support", "fixtures", "airbnb_reservation_1.txt"])
text = File.read!(fixture_path)
result = Parselet.parse(text, components: [AirbnbReservationComponent])
assert result.reservation_code == "ABC123XYZ"
assert result.guest_name == "Karina"
assert result.check_in_date == "Mar 28"
assert result.check_out_date == "Apr 3"
assert result.nights == 6
assert result.property_name == "Couzer A 2BR · Spacious 2br Unit"
assert result.guest_count == 3
assert result.check_in_time == "4:00 PM"
assert result.check_out_time == "10:00 AM"
assert result.earnings == 5452.22
end
test "parses airbnb reservation 2 fixture" do
fixture_path = Path.join([__DIR__, "support", "fixtures", "airbnb_reservation_2.txt"])
text = File.read!(fixture_path)
result = Parselet.parse(text, components: [AirbnbReservationComponent])
assert result.reservation_code == "HRH8HRYX6T"
assert result.guest_name == "James Bond"
assert result.check_in_date == "Feb 1"
assert result.check_out_date == "Feb 28"
assert result.nights == 16
assert result.property_name == "Your Dream Gateway"
assert result.guest_count == 6
assert result.check_in_time == "4:00 PM"
assert result.check_out_time == "10:00 AM"
assert result.earnings == 5603.00
end
end
describe "multi-components parsing" do
defmodule PersonalInfoComponent do
use Parselet.Component
field :name, pattern: ~r/Name:\s*(.+)/
field :age, pattern: ~r/Age:\s*(\d+)/, transform: &String.to_integer/1
field :email, pattern: ~r/Email:\s*(\S+@\S+)/
end
defmodule AddressComponent do
use Parselet.Component
field :street, pattern: ~r/Street:\s*(.+)/
field :city, pattern: ~r/City:\s*(.+)/
field :zip_code, pattern: ~r/ZIP:\s*(\d{5})/
end
defmodule EmploymentComponent do
use Parselet.Component
field :company, pattern: ~r/Company:\s*(.+)/
field :position, pattern: ~r/Position:\s*(.+)/
field :salary, pattern: ~r/Salary:\s*\$([\d,]+(?:\.\d{2})?)/, transform: fn(amount) ->
amount |> String.replace(",", "") |> String.to_float()
end
end
test "parses text using multiple components" do
text = """
Name: John Doe
Age: 35
Email: john.doe@example.com
Street: 123 Main St
City: Anytown
ZIP: 12345
Company: Tech Corp
Position: Senior Developer
Salary: $85,000.00
"""
result = Parselet.parse(text, components: [PersonalInfoComponent, AddressComponent, EmploymentComponent])
# Personal info fields
assert result.name == "John Doe"
assert result.age == 35
assert result.email == "john.doe@example.com"
# Address fields
assert result.street == "123 Main St"
assert result.city == "Anytown"
assert result.zip_code == "12345"
# Employment fields
assert result.company == "Tech Corp"
assert result.position == "Senior Developer"
assert result.salary == 85000.0
end
test "handles overlapping field names across components" do
defmodule OverlapComponentA do
use Parselet.Component
field :value, pattern: ~r/Value A:\s*(.+)/
end
defmodule OverlapComponentB do
use Parselet.Component
field :value, pattern: ~r/Value B:\s*(.+)/
end
text = "Value A: First\nValue B: Second"
result = Parselet.parse(text, components: [OverlapComponentA, OverlapComponentB])
# Last component wins for overlapping field names
assert result.value == "Second"
end
test "combines fields from multiple components with required validation" do
defmodule MultiRequiredComponent do
use Parselet.Component
field :required_field, pattern: ~r/Required:\s*(.+)/, required: true
end
defmodule MultiOptionalComponent do
use Parselet.Component
field :optional_field, pattern: ~r/Optional:\s*(.+)/
end
text = "Required: Important Data\nOptional: Extra Info"
result = Parselet.parse!(text, components: [MultiRequiredComponent, MultiOptionalComponent])
assert result.required_field == "Important Data"
assert result.optional_field == "Extra Info"
end
test "fails when required field in any component is missing" do
defmodule MultiStrictComponent do
use Parselet.Component
field :must_have, pattern: ~r/Must Have:\s*(.+)/, required: true
end
defmodule MultiLenientComponent do
use Parselet.Component
field :nice_to_have, pattern: ~r/Nice To Have:\s*(.+)/
end
text = "Nice To Have: Bonus Data"
# This should raise because MultiStrictComponent's required field is missing
assert_raise ArgumentError, "Missing required fields: [:must_have]", fn ->
Parselet.parse!(text, components: [MultiStrictComponent, MultiLenientComponent])
end
end
test "returns nested results when merge: false" do
text = """
Name: John Doe
Age: 35
Email: john.doe@example.com
Street: 123 Main St
City: Anytown
"""
result = Parselet.parse(text, components: [PersonalInfoComponent, AddressComponent], merge: false)
assert is_map(result)
assert Map.has_key?(result, PersonalInfoComponent)
assert Map.has_key?(result, AddressComponent)
personal_info = result[PersonalInfoComponent]
assert personal_info.name == "John Doe"
assert personal_info.age == 35
assert personal_info.email == "john.doe@example.com"
address_info = result[AddressComponent]
assert address_info.street == "123 Main St"
assert address_info.city == "Anytown"
refute Map.has_key?(address_info, :zip_code)
end
test "merge: true is default behavior" do
defmodule DefaultMergeComponent do
use Parselet.Component
field :name, pattern: ~r/Name:\s*(.+)/
field :age, pattern: ~r/Age:\s*(\d+)/, transform: &String.to_integer/1
end
text = "Name: Alice\nAge: 30"
merged_result = Parselet.parse(text, components: [DefaultMergeComponent])
explicit_merged_result = Parselet.parse(text, components: [DefaultMergeComponent], merge: true)
assert merged_result == explicit_merged_result
assert merged_result.name == "Alice"
assert merged_result.age == 30
end
test "structs: [Component] returns a struct for one component" do
defmodule StructComponent do
use Parselet.Component
field :name, pattern: ~r/Name:\s*(.+)/
field :age, pattern: ~r/Age:\s*(\d+)/, transform: &String.to_integer/1
end
text = "Name: Alice\nAge: 30"
result = Parselet.parse(text, structs: [StructComponent])
assert result.__struct__ == StructComponent
assert result.name == "Alice"
assert result.age == 30
end
test "structs: [ComponentA, ComponentB] returns map of structs for multiple components" do
defmodule StructComponentA do
use Parselet.Component
field :a, pattern: ~r/A:\s*(\w+)/, capture: :first
end
defmodule StructComponentB do
use Parselet.Component
field :b, pattern: ~r/B:\s*(\w+)/, capture: :first
end
text = "A: one\nB: two"
result = Parselet.parse(text, structs: [StructComponentA, StructComponentB])
assert result[StructComponentA].__struct__ == StructComponentA
assert result[StructComponentA].a == "one"
assert result[StructComponentB].__struct__ == StructComponentB
assert result[StructComponentB].b == "two"
end
test "parse/2 with structs and merge: false returns nested struct map" do
text = "Name: John Doe\nAge: 35\nEmail: john.doe@example.com\nStreet: 123 Main St\nCity: Anytown\nZIP: 12345"
result = Parselet.parse(text, structs: [PersonalInfoComponent, AddressComponent], merge: false)
assert result[PersonalInfoComponent].__struct__ == PersonalInfoComponent
assert result[AddressComponent].__struct__ == AddressComponent
assert result[PersonalInfoComponent].name == "John Doe"
assert result[AddressComponent].street == "123 Main St"
end
test "parse/2 with components and merge: false returns an error for failing nested postprocess" do
defmodule NestedFailComponent do
use Parselet.Component
field :status, pattern: ~r/Status:\s*(\w+)/, capture: :first
postprocess fn _fields -> {:error, :bad_end} end
end
defmodule GoodMergeComponent do
use Parselet.Component
field :name, pattern: ~r/Name:\s*(.+)/
end
text = "Name: Alice\nStatus: OK"
assert Parselet.parse(text, components: [GoodMergeComponent, NestedFailComponent], merge: false) ==
{:error, %{reason: ":bad_end", fields: []}}
end
test "parse/2 with components and structs: true returns a struct" do
text = "Name: John Doe\nAge: 35\nEmail: john.doe@example.com"
result = Parselet.parse(text, components: [PersonalInfoComponent], structs: true)
assert result.__struct__ == PersonalInfoComponent
assert result.name == "John Doe"
assert result.age == 35
assert result.email == "john.doe@example.com"
end
test "parse/2 with components and structs: false returns a merged map" do
text = "Name: John Doe\nAge: 35\nEmail: john.doe@example.com"
result = Parselet.parse(text, components: [PersonalInfoComponent], structs: false)
assert result.name == "John Doe"
assert result.age == 35
assert result.email == "john.doe@example.com"
refute Map.has_key?(result, :__struct__)
end
test "parse/2 raises when no components or structs are provided" do
assert_raise ArgumentError, "must pass either :components or :structs", fn ->
Parselet.parse("text", [])
end
end
test "parse/2 raises when invalid options are provided" do
assert_raise ArgumentError, ":components must be a list or :structs must be a list", fn ->
Parselet.parse("text", components: :invalid)
end
end
test "parse! with structs: [Component] and required field" do
defmodule StructRequiredComponent do
use Parselet.Component
field :id, pattern: ~r/ID:\s*(\d+)/, capture: :first, required: true
end
text = "ID: 123"
result = Parselet.parse!(text, structs: [StructRequiredComponent])
assert result.__struct__ == StructRequiredComponent
assert result.id == "123"
end
test "validates required fields in nested results" do
defmodule NestedRequiredComponent do
use Parselet.Component
field :required_data, pattern: ~r/Required:\s*(.+)/, required: true
end
defmodule NestedOptionalComponent do
use Parselet.Component
field :optional_data, pattern: ~r/Optional:\s*(.+)/
end
text = "Optional: Some data"
assert_raise ArgumentError, "Missing required fields: [:required_data]", fn ->
Parselet.parse!(text, components: [NestedRequiredComponent, NestedOptionalComponent], merge: false)
end
end
end
end