feat(cli): add output rendering framework with materialization strategies

This commit is contained in:
2026-02-20 18:52:10 +00:00
parent eae18c7578
commit 1df21e6a64
14 changed files with 3563 additions and 24 deletions
+372
View File
@@ -0,0 +1,372 @@
Feature: Output Rendering Framework
The output rendering framework decouples command output data from visual
presentation through a session/handle/materialiser pipeline.
# --- OutputSession lifecycle ---
Scenario: OutputSession opens and closes cleanly
Given an OutputSession with format "plain"
When the session is closed
Then the session state is "closed"
Scenario: OutputSession works as a context manager
When I use an OutputSession context manager with format "plain"
Then the session was closed automatically
Scenario: OutputSession context manager sets exit_code on exception
When I use an OutputSession context manager that raises an exception
Then the session exit code is 1
# --- PanelHandle ---
Scenario: PanelHandle set_entry adds key-value pairs
Given an OutputSession with format "plain"
When I create a panel handle with title "Details"
And I set entry "Name" to "test-project"
And I set entry "Status" to "active"
And I close the panel handle
Then the panel has 2 entries
And the panel entry "Name" has value "test-project"
Scenario: PanelHandle set_entry updates existing key
Given an OutputSession with format "plain"
When I create a panel handle with title "Details"
And I set entry "Name" to "old-value"
And I set entry "Name" to "new-value"
Then the panel entry "Name" has value "new-value"
And the panel has 1 entries
Scenario: PanelHandle set_entries batch update
Given an OutputSession with format "plain"
When I create a panel handle with title "Details"
And I batch set entries with keys "A,B,C" and values "1,2,3"
Then the panel has 3 entries
Scenario: PanelHandle remove_entry removes a key
Given an OutputSession with format "plain"
When I create a panel handle with title "Details"
And I set entry "Name" to "value"
And I set entry "Extra" to "remove-me"
And I remove entry "Extra"
Then the panel has 1 entries
Scenario: PanelHandle add_line convenience alias
Given an OutputSession with format "plain"
When I create a panel handle with title "Details"
And I add line "Key" with value "Val"
Then the panel entry "Key" has value "Val"
Scenario: Writing to a closed PanelHandle raises ElementClosedError
Given an OutputSession with format "plain"
When I create a panel handle with title "Details"
And I close the panel handle
Then setting entry "X" to "Y" raises ElementClosedError
# --- TableHandle ---
Scenario: TableHandle add_row with dict
Given an OutputSession with format "plain"
When I create a table handle with columns "Name,Status"
And I add a dict row with Name "foo" and Status "active"
Then the table has 1 rows
Scenario: TableHandle add_row with list
Given an OutputSession with format "plain"
When I create a table handle with columns "Name,Status"
And I add a list row with values "bar,inactive"
Then the table has 1 rows
Scenario: TableHandle add_rows batch
Given an OutputSession with format "plain"
When I create a table handle with columns "Name,Status"
And I add 3 batch rows
Then the table has 3 rows
Scenario: TableHandle set_summary
Given an OutputSession with format "plain"
When I create a table handle with columns "Name,Count"
And I add a dict row with Name "a" and Status "1"
And I set summary with total "1"
Then the table summary is set
Scenario: TableHandle set_sort_key
Given an OutputSession with format "plain"
When I create a table handle with columns "Name,Status"
And I set sort key to "Name"
Then the table sort key is "Name"
Scenario: Writing to a closed TableHandle raises ElementClosedError
Given an OutputSession with format "plain"
When I create a table handle with columns "Name,Status"
And I close the table handle
Then adding a row raises ElementClosedError
# --- StatusHandle ---
Scenario: StatusHandle creation and update
Given an OutputSession with format "plain"
When I create a status handle with message "Working..."
And I update the status message to "Done"
And I update the status level to "ok"
And I set the status detail to "All tasks complete"
Then the status message is "Done"
And the status level is "ok"
And the status detail is "All tasks complete"
Scenario: StatusHandle rejects invalid level
Given an OutputSession with format "plain"
When I create a status handle with message "Test"
Then setting status level to "invalid" raises ValueError
# --- ProgressHandle ---
Scenario: ProgressHandle set_progress updates current and total
Given an OutputSession with format "plain"
When I create a progress handle labelled "Uploading"
And I set progress to 50 of 100
Then the progress current is 50
And the progress total is 100
Scenario: ProgressHandle tick increments by 1
Given an OutputSession with format "plain"
When I create a progress handle labelled "Processing"
And I tick the progress 3 times
Then the progress current is 3
Scenario: ProgressHandle increment by delta
Given an OutputSession with format "plain"
When I create a progress handle labelled "Processing"
And I increment progress by 5
Then the progress current is 5
Scenario: ProgressHandle set_label changes label
Given an OutputSession with format "plain"
When I create a progress handle labelled "Old Label"
And I set progress label to "New Label"
Then the progress label is "New Label"
Scenario: ProgressHandle set_indeterminate enables spinner mode
Given an OutputSession with format "plain"
When I create a progress handle labelled "Loading"
And I set progress to indeterminate mode
Then the progress is indeterminate
Scenario: ProgressHandle set_step_status tracks steps
Given an OutputSession with format "plain"
When I create a progress handle labelled "Pipeline" with steps "A,B,C"
And I set step "A" status to "done"
And I set step "B" status to "active"
Then step "A" has status "done"
And step "B" has status "active"
And step "C" has status "pending"
Scenario: ProgressHandle rejects negative current
Given an OutputSession with format "plain"
When I create a progress handle labelled "Test"
Then setting progress to -1 raises ValueError
Scenario: ProgressHandle rejects invalid step status
Given an OutputSession with format "plain"
When I create a progress handle labelled "Test"
Then setting step "X" to status "bogus" raises ValueError
# --- PlainMaterializer ---
Scenario: PlainMaterializer renders panel as plain ASCII
Given an OutputSession with format "plain" using PlainMaterializer
When I create a panel handle with title "Info"
And I set entry "Name" to "test"
And I set entry "Value" to "42"
And I close the panel handle
And the session is closed
Then the plain output contains "Info"
And the plain output contains "Name: test"
And the plain output contains "Value: 42"
And the plain output does not contain ANSI codes
Scenario: PlainMaterializer renders table as plain ASCII
Given an OutputSession with format "plain" using PlainMaterializer
When I create a table handle with columns "Name,Status"
And I add a dict row with Name "foo" and Status "active"
And I close the table handle
And the session is closed
Then the plain output contains "Name"
And the plain output contains "foo"
And the plain output does not contain ANSI codes
Scenario: PlainMaterializer renders status as plain ASCII
Given an OutputSession with format "plain" using PlainMaterializer
When I create a status handle with message "All good"
And I update the status level to "ok"
And I close the status handle
And the session is closed
Then the plain output contains "[OK]"
And the plain output contains "All good"
Scenario: PlainMaterializer renders progress as plain ASCII
Given an OutputSession with format "plain" using PlainMaterializer
When I create a progress handle labelled "Downloading" with total 100
And I set progress to 75 of 100
And I close the progress handle
And the session is closed
Then the plain output contains "Downloading"
And the plain output contains "75/100"
# --- ColorMaterializer ---
Scenario: ColorMaterializer renders panel with ANSI codes
Given an OutputSession with format "color" using ColorMaterializer
When I create a panel handle with title "Coloured"
And I set entry "Key" to "val"
And I close the panel handle
And the session is closed
Then the color output contains ANSI codes
And the color output contains "Coloured"
Scenario: ColorMaterializer renders status with ANSI codes
Given an OutputSession with format "color" using ColorMaterializer
When I create a status handle with message "Warning text"
And I update the status level to "warn"
And I close the status handle
And the session is closed
Then the color output contains "[WARN]"
And the color output contains ANSI codes
# --- TableMaterializer ---
Scenario: TableMaterializer renders table with box-drawing
Given an OutputSession with format "table" using TableMaterializer
When I create a table handle with columns "Name,Status"
And I add a dict row with Name "svc" and Status "up"
And I close the table handle
And the session is closed
Then the table output contains box-drawing characters
And the table output contains "svc"
# --- RichMaterializer ---
Scenario: RichMaterializer renders output
Given an OutputSession with format "rich" using RichMaterializer
When I create a panel handle with title "Rich Panel"
And I set entry "Hello" to "World"
And I close the panel handle
And the session is closed
Then the rich output contains "Rich Panel"
# --- JsonMaterializer ---
Scenario: JsonMaterializer serialises all elements as JSON
Given an OutputSession with format "json" using JsonMaterializer
When I create a panel handle with title "JSON Panel"
And I set entry "Key1" to "Val1"
And I close the panel handle
And the session is closed
Then the json output is valid JSON
And the json output contains element type "panel"
And the json output contains "Key1"
Scenario: JsonMaterializer includes tables in output
Given an OutputSession with format "json" using JsonMaterializer
When I create a table handle with columns "Col1,Col2"
And I add a dict row with Name "Col1" and Status "Val1"
And I close the table handle
And the session is closed
Then the json output is valid JSON
And the json output contains element type "table"
# --- YamlMaterializer ---
Scenario: YamlMaterializer serialises all elements as YAML
Given an OutputSession with format "yaml" using YamlMaterializer
When I create a panel handle with title "YAML Panel"
And I set entry "Alpha" to "Beta"
And I close the panel handle
And the session is closed
Then the yaml output is valid YAML
And the yaml output contains "Alpha"
# --- Error envelope ---
Scenario: JsonMaterializer error envelope
Given a JsonMaterializer
When I generate an error envelope with code "NOT_FOUND" and message "Resource not found"
Then the json error output contains "NOT_FOUND"
And the json error output contains "Resource not found"
Scenario: YamlMaterializer error envelope
Given a YamlMaterializer
When I generate a yaml error envelope with code "FORBIDDEN" and message "Access denied"
Then the yaml error output contains "FORBIDDEN"
And the yaml error output contains "Access denied"
# --- Concurrent producers ---
Scenario: Concurrent producers do not interleave in plain mode
Given an OutputSession with format "plain" using PlainMaterializer
When I create two panels concurrently and close them out of order
And the session is closed
Then the plain output has panel A before panel B
# --- Format fallback ---
Scenario: Rich format falls back to plain when no TTY
Given a non-TTY terminal environment
When I select a materializer for format "rich"
Then the selected materializer is PlainMaterializer
Scenario: Color format falls back to plain when no ANSI support
Given a terminal without ANSI support
When I select a materializer for format "color"
Then the selected materializer is PlainMaterializer
Scenario: Plain format always works
Given a non-TTY terminal environment
When I select a materializer for format "plain"
Then the selected materializer is PlainMaterializer
Scenario: JSON format ignores terminal capabilities
Given a non-TTY terminal environment
When I select a materializer for format "json"
Then the selected materializer is JsonMaterializer
Scenario: Invalid format raises ValueError
When I attempt to select a materializer for format "invalid"
Then a ValueError is raised
# --- Backward compatibility ---
Scenario: format_output still works with plain format
When I call format_output with a dict and format "plain"
Then the output contains key-value pairs
Scenario: format_output still works with json format
When I call format_output with a dict and format "json"
Then the output is valid JSON
Scenario: format_output_session renders via OutputSession
When I call format_output_session with a dict and format "plain"
Then the session output contains the dict keys
# --- Handle context manager ---
Scenario: Element handle works as context manager
Given an OutputSession with format "plain"
When I use a panel handle as a context manager
Then the handle is closed after the context exits
# --- Snapshot ---
Scenario: Session snapshot captures all elements
Given an OutputSession with format "plain"
When I create a panel handle with title "P1"
And I set entry "K" to "V"
And I create a table handle with columns "A,B"
And I add a dict row with Name "A" and Status "B"
Then the session snapshot has 2 elements
# --- Materializer selection with explicit flag ---
Scenario: Explicit format overrides fallback
Given a non-TTY terminal environment
When I select a materializer for format "rich" with explicit flag
Then the selected materializer is RichMaterializer