Skip to content

Commit 77b9a24

Browse files
Add term validation tests with test data
Adds comprehensive term validation tests along with their test data files. Test data files (5 files): - terms.ftl: Basic term definitions and references - terms_definitions.ftl: Terms for cross-file testing - terms_messages.ftl: Messages referencing cross-file terms - broken_terms.ftl: Invalid term references for error testing - term_positional_args.ftl: Terms with positional arguments Test updates: - Updated parser error test for new error message format - Added test_parser_error_has_structured_error_details New term validation tests (13 tests): - test_basic_term_reference - test_term_attribute_as_selector - test_multiple_term_references - test_direct_term_access_fails - test_unknown_term_validation_error - test_unknown_term_attribute_validation_error - test_terms_across_multiple_files - test_strict_mode_rejects_unknown_terms - test_term_positional_arguments_generate_validation_error - test_strict_mode_rejects_term_positional_arguments - test_term_positional_arguments_validation_has_correct_context Tests validate term definitions, cross-file resolution, error detection, and positional argument warnings.
1 parent faa20cb commit 77b9a24

File tree

6 files changed

+271
-10
lines changed

6 files changed

+271
-10
lines changed

tests/data/broken_terms.ftl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# File with broken term references for testing validation
2+
3+
-valid-term = This is a valid term
4+
.case = nominative
5+
6+
# Message references non-existent term
7+
broken-reference = This references { -nonexistent-term }
8+
9+
# Message uses non-existent term attribute as selector
10+
broken-attribute = { -valid-term.nonexistent ->
11+
[value] Something
12+
*[other] Other
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-brand-name = { $case ->
2+
*[nominative] Firefox
3+
[locative] Firefoxie
4+
}
5+
6+
# This message uses a positional argument to a term (which is ignored per spec)
7+
bad-reference = Visit { -brand-name("positional") } for more info.
8+
9+
# This message uses both positional and named arguments
10+
bad-reference-mixed = About { -brand-name("ignored", case: "locative") }.
11+
12+
# This is the correct way - only named arguments
13+
good-reference = About { -brand-name(case: "locative") }.

tests/data/terms.ftl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Test file with Fluent terms
2+
-brand-name = Acme Corporation
3+
4+
-product-name = Super Widget
5+
.category = gadget
6+
7+
# Messages that reference terms
8+
welcome = Welcome to { -brand-name }!
9+
product-info = Learn about { -product-name }
10+
11+
# Message using term attribute as selector (this is valid per Fluent spec)
12+
product-category = { -product-name.category ->
13+
[gadget] This is a gadget
14+
*[other] This is something else
15+
}

tests/data/terms_definitions.ftl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Term definitions file
2+
# These terms can be referenced from other files
3+
4+
-app-name = SuperApp
5+
6+
-app-type =
7+
{ $type ->
8+
[mobile] Mobile App
9+
*[web] Web App
10+
}
11+
12+
-company-name = Acme Corporation

tests/data/terms_messages.ftl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Messages that reference terms from terms_definitions.ftl
2+
3+
about-app = About { -app-name }
4+
app-title = { -app-name } - The Best App
5+
app-description = This is a { -app-type(type: "web") }
6+
company-info = Brought to you by { -company-name }

tests/test_python_interface.py

Lines changed: 212 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -192,23 +192,58 @@ def test_raises_parser_error_on_file_that_contains_errors_in_strict_mode():
192192
message = "\n".join(lines)
193193
# End recombination
194194

195-
expected = f"""\
196-
× Error when parsing {filename}
197-
╭─[1:16]
198-
1 │ invalid-message
199-
· ┬
200-
· ╰── Expected a token starting with "="
201-
2 │
202-
3 │ valid-message = I'm valid.
203-
╰────
204-
"""
195+
# Note: Line 2 has a trailing space after the pipe
196+
expected = ' × Found 1 parse error(s) and 0 validation error(s)\n ╭─[1:16]\n 1 │ invalid-message\n · ┬\n · ╰── Expected a token starting with "="\n 2 │ \n 3 │ valid-message = I\'m valid.\n ╰────\n'
205197
assert message == expected
206198

207199

208200
def test_parser_error_str():
209201
assert str(fluent.ParserError) == "<class 'rustfluent.ParserError'>"
210202

211203

204+
def test_parser_error_has_structured_error_details():
205+
"""Test that ParserError exposes structured error information."""
206+
filename = data_dir / "errors.ftl"
207+
208+
with pytest.raises(fluent.ParserError) as exc_info:
209+
fluent.Bundle("fr", [filename], strict=True)
210+
211+
error = exc_info.value
212+
213+
# Verify the parse_errors attribute exists
214+
assert hasattr(error, "parse_errors"), "ParserError should have 'parse_errors' attribute"
215+
assert len(error.parse_errors) == 1, "Should have exactly one error"
216+
217+
# Verify the structure of the error detail
218+
error_detail = error.parse_errors[0]
219+
assert type(error_detail).__name__ == "ParseErrorDetail"
220+
221+
# Verify all expected attributes exist and have correct values
222+
assert hasattr(error_detail, "message")
223+
assert hasattr(error_detail, "line")
224+
assert hasattr(error_detail, "column")
225+
assert hasattr(error_detail, "byte_start")
226+
assert hasattr(error_detail, "byte_end")
227+
assert hasattr(error_detail, "filename")
228+
229+
# Verify the error is at the expected location
230+
assert error_detail.line == 1
231+
assert error_detail.column == 16
232+
assert error_detail.byte_start == 15
233+
assert error_detail.byte_end == 16
234+
235+
# Verify the message contains expected content
236+
assert 'Expected a token starting with "="' in error_detail.message
237+
238+
# Verify filename is included
239+
assert str(filename) in error_detail.filename
240+
241+
# Verify string representation works
242+
error_str = str(error_detail)
243+
assert "1:16" in error_str # Line:column should be in string representation
244+
assert "Expected a token" in error_str
245+
246+
212247
# Attribute access tests
213248

214249

@@ -288,3 +323,170 @@ def test_missing_attribute_raises_error():
288323
def test_attribute_and_message_access_parameterized(identifier, expected):
289324
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
290325
assert bundle.get_translation(identifier) == expected
326+
327+
328+
# ==============================================================================
329+
# Term Tests
330+
# ==============================================================================
331+
# Tests for Fluent terms - reusable vocabulary items that start with "-"
332+
# and can only be referenced within messages (not retrieved directly)
333+
334+
335+
def test_basic_term_reference():
336+
"""Test that messages can reference terms using { -term-name } syntax."""
337+
bundle = fluent.Bundle("en", [data_dir / "terms.ftl"])
338+
result = bundle.get_translation("welcome")
339+
assert result == "Welcome to Acme Corporation!"
340+
341+
342+
def test_term_attribute_as_selector():
343+
"""Test that term attributes can be used as selectors in select expressions.
344+
345+
Note: Per Fluent spec, term attributes are private and can only be used
346+
as selectors, not as direct placeables like { -term.attribute }.
347+
"""
348+
bundle = fluent.Bundle("en", [data_dir / "terms.ftl"])
349+
result = bundle.get_translation("product-category")
350+
assert result == "This is a gadget"
351+
352+
353+
def test_multiple_term_references():
354+
"""Test that multiple messages can reference the same term."""
355+
bundle = fluent.Bundle("en", [data_dir / "terms.ftl"])
356+
assert bundle.get_translation("welcome") == "Welcome to Acme Corporation!"
357+
assert bundle.get_translation("product-info") == "Learn about Super Widget"
358+
359+
360+
def test_direct_term_access_fails():
361+
"""Test that terms cannot be retrieved directly per Fluent spec."""
362+
bundle = fluent.Bundle("en", [data_dir / "terms.ftl"])
363+
with pytest.raises(ValueError, match="-brand-name not found"):
364+
bundle.get_translation("-brand-name")
365+
366+
367+
def test_unknown_term_validation_error():
368+
"""Test that referencing a non-existent term generates a validation error."""
369+
bundle = fluent.Bundle("en", [data_dir / "broken_terms.ftl"])
370+
371+
# Should have validation errors
372+
validation_errors = bundle.get_validation_errors()
373+
assert len(validation_errors) == 2 # One for unknown term, one for unknown attribute
374+
375+
# Find the unknown term error
376+
unknown_term_errors = [e for e in validation_errors if e.error_type == "UnknownTerm"]
377+
assert len(unknown_term_errors) == 1
378+
379+
error = unknown_term_errors[0]
380+
assert error.error_type == "UnknownTerm"
381+
assert "-nonexistent-term" in error.message or "nonexistent-term" in error.message
382+
assert error.message_id == "broken-reference"
383+
384+
385+
def test_unknown_term_attribute_validation_error():
386+
"""Test that referencing a non-existent term attribute generates a validation error."""
387+
bundle = fluent.Bundle("en", [data_dir / "broken_terms.ftl"])
388+
389+
# Should have validation errors
390+
validation_errors = bundle.get_validation_errors()
391+
assert len(validation_errors) == 2 # One for unknown term, one for unknown attribute
392+
393+
# Find the unknown attribute error
394+
unknown_attr_errors = [e for e in validation_errors if e.error_type == "UnknownAttribute"]
395+
assert len(unknown_attr_errors) == 1
396+
397+
error = unknown_attr_errors[0]
398+
assert error.error_type == "UnknownAttribute"
399+
assert "nonexistent" in error.message
400+
assert error.message_id == "broken-attribute"
401+
402+
403+
def test_terms_across_multiple_files():
404+
"""Test that terms defined in one file can be referenced in messages from another file."""
405+
# Load terms file first, then messages file
406+
bundle = fluent.Bundle(
407+
"en", [data_dir / "terms_definitions.ftl", data_dir / "terms_messages.ftl"]
408+
)
409+
410+
# Should have no validation errors
411+
assert len(bundle.get_validation_errors()) == 0
412+
413+
# Verify messages correctly reference terms from the other file
414+
assert bundle.get_translation("about-app") == "About SuperApp"
415+
assert bundle.get_translation("app-title") == "SuperApp - The Best App"
416+
assert bundle.get_translation("app-description") == "This is a Web App"
417+
assert bundle.get_translation("company-info") == "Brought to you by Acme Corporation"
418+
419+
420+
def test_strict_mode_rejects_unknown_terms():
421+
"""Test that strict mode raises an error when unknown terms are referenced.
422+
423+
Note: When there are only validation errors (no parse errors), a ValueError
424+
is raised. When there are parse errors, a ParserError is raised.
425+
"""
426+
with pytest.raises(ValueError) as exc_info:
427+
fluent.Bundle("en", [data_dir / "broken_terms.ftl"], strict=True)
428+
429+
error = exc_info.value
430+
431+
# Should have validation errors
432+
assert hasattr(error, "validation_errors")
433+
assert len(error.validation_errors) == 2
434+
435+
# Check that the errors mention the unknown term and attribute
436+
error_messages = [e.message for e in error.validation_errors]
437+
assert any("nonexistent-term" in msg for msg in error_messages)
438+
assert any("nonexistent" in msg and "attribute" in msg.lower() for msg in error_messages)
439+
440+
441+
def test_term_positional_arguments_generate_validation_error():
442+
"""Test that positional arguments to terms generate a validation error.
443+
444+
Per Fluent spec, positional arguments to terms are syntactically valid but
445+
semantically ignored at runtime. We warn about them to prevent confusion.
446+
"""
447+
bundle = fluent.Bundle("en", [data_dir / "term_positional_args.ftl"])
448+
449+
# Should have validation errors for the two messages with positional args
450+
validation_errors = bundle.get_validation_errors()
451+
assert len(validation_errors) == 2
452+
453+
# Both errors should be about ignored positional arguments
454+
for error in validation_errors:
455+
assert error.error_type == "IgnoredPositionalArgument"
456+
assert "positional" in error.message.lower() or "ignored" in error.message.lower()
457+
assert "-brand-name" in error.message
458+
assert error.message_id in ["bad-reference", "bad-reference-mixed"]
459+
460+
# The good reference should still work correctly
461+
assert bundle.get_translation("good-reference") == "About Firefoxie."
462+
463+
464+
def test_strict_mode_rejects_term_positional_arguments():
465+
"""Test that strict mode raises an error when terms are given positional arguments."""
466+
with pytest.raises(ValueError) as exc_info:
467+
fluent.Bundle("en", [data_dir / "term_positional_args.ftl"], strict=True)
468+
469+
error = exc_info.value
470+
471+
# Should have validation errors
472+
assert hasattr(error, "validation_errors")
473+
assert len(error.validation_errors) == 2
474+
475+
# Check that the errors mention positional arguments
476+
error_messages = [e.message for e in error.validation_errors]
477+
assert all("positional" in msg.lower() or "ignored" in msg.lower() for msg in error_messages)
478+
479+
480+
def test_term_positional_arguments_validation_has_correct_context():
481+
"""Test that positional argument validation errors have correct context."""
482+
bundle = fluent.Bundle("en", [data_dir / "term_positional_args.ftl"])
483+
484+
validation_errors = bundle.get_validation_errors()
485+
486+
# Find the error for bad-reference
487+
bad_ref_error = next(e for e in validation_errors if e.message_id == "bad-reference")
488+
489+
assert bad_ref_error.error_type == "IgnoredPositionalArgument"
490+
assert bad_ref_error.message_id == "bad-reference"
491+
assert bad_ref_error.reference == "-brand-name"
492+
assert "named arguments" in bad_ref_error.message

0 commit comments

Comments
 (0)