merge: panel ticket two-line rows

This commit is contained in:
Keisuke Hirata 2026-06-14 15:40:45 +09:00
commit 98357b8aa2
No known key found for this signature in database
4 changed files with 318 additions and 110 deletions

View File

@ -2,7 +2,7 @@
title: 'Panel Ticket rows を2行表示にして gate 情報を分離する'
state: 'inprogress'
created_at: '2026-06-13T18:10:57Z'
updated_at: '2026-06-14T06:10:45Z'
updated_at: '2026-06-14T06:37:08Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui', 'workspace-panel', 'ticket-relations', 'mouse-input', 'layout']

View File

@ -45,4 +45,65 @@ Validation:
Ticket evidence, related records, orchestration plan, and clean workspace state were checked. No blockers remain; accept for implementation before worktree/spawn side effects.
---
<!-- event: implementation_report author: hare at: 2026-06-14T06:26:55Z -->
## Implementation report
Implemented Workspace Panel Ticket row rendering as two visual lines.
Changes:
- Ticket action rows now render a state/title line and a separate detail line containing Ticket id, gate, action, and reason.
- Selection stays logical per Ticket row: selected Ticket visual lines use the same `|` marker, and contiguous hit boxes for the same Ticket are grouped so mouse clicks on either line select the same Ticket without dispatching actions.
- Relation blockers are shown as gate/wait information with queue disabled wording and `Wait` action rather than as blocked/error/human-reply styling; no persisted `waiting` lifecycle state was added.
- Updated focused row layout, waiting-gate, and mouse hit-testing tests.
Validation:
- PASS: `cargo test -p tui panel_ticket --lib`
- PASS: `cargo test -p tui row_hit_testing --lib`
- PASS: `cargo test -p tui mouse_click --lib`
- PASS: `cargo test -p tui ready_ticket_with_waiting_gate --lib`
- PASS: `cargo test -p tui workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate --lib`
- PASS: `cargo test -p yoi-e2e --features e2e --test panel -- --nocapture`
- PASS: `cargo build -p yoi`
- PASS: `cargo fmt --check`
- PASS: `git diff --check`
- NOTE: `cargo test -p tui --lib` was also attempted and failed in pre-existing/unrelated tests: `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`, `spawn::tests::profile_choices_include_builtin_and_project_default_marker`, and `spawn::tests::profile_choices_use_project_registry_default`. The failures assert unrelated orchestration/profile defaults and are outside this Ticket's row/layout/mouse changes.
- NOT RUN: `nix build .#yoi` (skipped as nonessential here; it may create root-level build output symlinks and is heavier than the scoped TUI/panel validation).
Risks:
- Ticket rows now consume two terminal lines, so very short panel areas may display fewer logical Ticket rows before clipping.
---
<!-- event: review author: hare at: 2026-06-14T06:37:08Z status: approve -->
## Review: approve
Approve.
Evidence:
- Reviewed `git diff f709fc10..HEAD` for implementation commit `645d048d`.
- Ticket action rows now expand each Ticket into two selectable visual rows: the first renders canonical state plus title, the second renders Ticket id, derived gate, action, and reason. The selected marker is the `| ` grouping marker on both visual lines, and the title line no longer embeds the Ticket id.
- Hit testing merges adjacent visual rows with the same `PanelRowKey`, so both Ticket visual lines select the same logical row/hitbox; mouse selection does not dispatch by itself, with dispatch still requiring the existing blank-Enter path.
- Relation blockers are derived from relation data into `Wait`/queue-disabled gate text and preserve the canonical workflow state rather than introducing a persisted `waiting` lifecycle state or the old blocked/edit/human-reply row class.
- Short-area/list-row slicing remains bounded by available height and hitboxes ignore out-of-area rows, so very small panel areas degrade without panics.
Validation:
- `cargo test -p tui panel_ticket --lib` — passed (3 tests).
- `cargo test -p tui row_hit_testing --lib` — passed (1 test).
- `cargo test -p tui mouse_click --lib` — passed (2 tests).
- `cargo test -p tui ready_ticket_with_waiting_gate --lib` — passed (1 test).
- `cargo test -p tui workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate --lib` — passed (1 test).
- `cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed (3 tests).
- `cargo build -p yoi` — passed.
- `cargo fmt --check` — passed.
- `git diff --check f709fc10..HEAD` — passed.
Risks / unresolved:
- None found within the requested review focus.
---

View File

@ -3753,16 +3753,15 @@ async fn dispatch_ticket_action(
.await
}
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
NextUserAction::Clarify
| NextUserAction::Edit
| NextUserAction::OpenPod
| NextUserAction::Wait => Ok(TicketActionOutcome {
notice: format!(
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
request.action.label(),
current_ticket.id
),
}),
NextUserAction::Clarify | NextUserAction::OpenPod | NextUserAction::Wait => {
Ok(TicketActionOutcome {
notice: format!(
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
request.action.label(),
current_ticket.id
),
})
}
}
}
@ -5094,19 +5093,34 @@ fn row_hit_boxes(rows: &[PanelListRow], area: Rect) -> Vec<PanelRowHitBox> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
rows.iter()
.enumerate()
.filter_map(|(offset, row)| {
let y = area.y.checked_add(offset as u16)?;
if y >= area.y.saturating_add(area.height) {
return None;
let mut hit_boxes: Vec<PanelRowHitBox> = Vec::new();
for (offset, row) in rows.iter().enumerate() {
let Some(key) = row.key.clone() else {
continue;
};
let Some(y) = area.y.checked_add(offset as u16) else {
continue;
};
if y >= area.y.saturating_add(area.height) {
continue;
}
if let Some(last) = hit_boxes.last_mut() {
if last.key == key
&& last.rect.x == area.x
&& last.rect.width == area.width
&& last.rect.y.saturating_add(last.rect.height) == y
{
last.rect.height = last.rect.height.saturating_add(1);
continue;
}
Some(PanelRowHitBox {
rect: Rect::new(area.x, y, area.width, 1),
key: row.key.clone()?,
})
})
.collect()
}
hit_boxes.push(PanelRowHitBox {
rect: Rect::new(area.x, y, area.width, 1),
key,
});
}
hit_boxes
}
fn panel_diagnostic_lines(panel: &WorkspacePanelViewModel, width: u16) -> Vec<Line<'static>> {
@ -5139,16 +5153,15 @@ fn panel_action_rows(
if rows.is_empty() {
return Vec::new();
}
let mut lines = Vec::with_capacity(rows.len() + 1);
let mut lines = Vec::with_capacity((rows.len() * 2) + 1);
lines.push(PanelListRow::inert(panel_action_header_line(
rows.len(),
width,
)));
for row in rows {
lines.push(PanelListRow::selectable(
panel_row_line(row, selected == Some(&row.key), width),
row.key.clone(),
));
for line in panel_row_lines(row, selected == Some(&row.key), width) {
lines.push(PanelListRow::selectable(line, row.key.clone()));
}
}
lines
}
@ -5169,11 +5182,16 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> {
}
const TICKET_STATE_COLUMN_WIDTH: usize = 10;
const TICKET_ID_COLUMN_WIDTH: usize = 13;
const POD_STATUS_COLUMN_WIDTH: usize = 18;
fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let marker = if selected { "" } else { " " };
fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> [Line<'static>; 2] {
[
panel_row_title_line(row, selected, width),
panel_row_detail_line(row, selected, width),
]
}
fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let title_style = if selected {
Style::default()
.fg(Color::Magenta)
@ -5181,22 +5199,10 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
} else {
Style::default().fg(Color::Magenta)
};
let ticket_ref = panel_ticket_reference(row);
let mut spans = Vec::new();
let mut remaining = width as usize;
push_bounded_span(
&mut spans,
marker,
if selected {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
},
&mut remaining,
);
push_ticket_marker_span(&mut spans, selected, &mut remaining);
push_column_span(
&mut spans,
&row.status,
@ -5204,18 +5210,96 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
panel_priority_style(row.priority),
&mut remaining,
);
push_column_span(
&mut spans,
&ticket_ref,
TICKET_ID_COLUMN_WIDTH,
Style::default().fg(Color::DarkGray),
&mut remaining,
);
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
Line::from(spans)
}
fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let mut spans = Vec::new();
let mut remaining = width as usize;
push_ticket_marker_span(&mut spans, selected, &mut remaining);
push_bounded_span(
&mut spans,
&panel_ticket_detail(row),
ticket_detail_style(row),
&mut remaining,
);
Line::from(spans)
}
fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remaining: &mut usize) {
let (marker, style) = if selected {
(
"| ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default().fg(Color::DarkGray))
};
push_bounded_span(spans, marker, style, remaining);
}
fn panel_ticket_detail(row: &PanelRow) -> String {
let mut parts = vec![panel_ticket_reference(row)];
if let Some(blocked_reason) = row
.ticket
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_deref())
{
parts.push(format!("Gate: waiting for {blocked_reason}"));
} else {
parts.push("Gate: clear".to_string());
}
if let Some(action) = row.next_action {
parts.push(format!(
"Action: {}",
panel_ticket_action_label(row, action)
));
}
if let Some(reason) = panel_ticket_reason(row) {
parts.push(format!("Reason: {reason}"));
}
parts.join(" · ")
}
fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static str {
if action == NextUserAction::Wait
&& row
.ticket
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_ref())
.is_some()
{
"queue disabled"
} else {
action.label()
}
}
fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
row.disabled_reason
.as_deref()
.or_else(|| row.key_hint.as_deref())
}
fn ticket_detail_style(row: &PanelRow) -> Style {
if row
.ticket
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_ref())
.is_some()
{
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
}
}
fn panel_ticket_reference(row: &PanelRow) -> String {
row.ticket
.as_ref()
@ -5264,7 +5348,6 @@ fn padded_cell(value: &str, width: usize) -> String {
fn panel_priority_style(priority: ActionPriority) -> Style {
match priority {
ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
ActionPriority::Background => Style::default().fg(Color::DarkGray),
@ -5379,8 +5462,11 @@ fn target_status_line(app: &MultiPodApp) -> Line<'static> {
.selected_panel_row()
.filter(|row| row.is_ticket_action())
{
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
Line::from(vec![
let action = row
.next_action
.map(|action| panel_ticket_action_label(row, action))
.unwrap_or("View");
let mut spans = vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
@ -5392,7 +5478,15 @@ fn target_status_line(app: &MultiPodApp) -> Line<'static> {
Span::styled(row.status.clone(), panel_priority_style(row.priority)),
Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)),
Span::styled(action, Style::default().fg(Color::Magenta)),
])
];
if let Some(reason) = panel_ticket_reason(row) {
spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
truncate_with_ellipsis(reason, 100),
ticket_detail_style(row),
));
}
Line::from(spans)
} else if let Some(entry) = app.selected_pod_entry() {
let (status, status_style) = row_status_label(entry);
Line::from(vec![
@ -6694,10 +6788,11 @@ branch = "orchestration/custom-panel"
assert_eq!(boxes.len(), 3);
assert_eq!(boxes[0].key, PanelRowKey::Ticket("TICKET-1".into()));
assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 1));
assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 2));
assert_eq!(boxes[1].key, PanelRowKey::Ticket("TICKET-2".into()));
assert_eq!(boxes[1].rect, Rect::new(3, 7, 80, 1));
assert_eq!(boxes[1].rect, Rect::new(3, 8, 80, 2));
assert_eq!(boxes[2].key, PanelRowKey::Pod("alpha".into()));
assert_eq!(boxes[2].rect, Rect::new(3, 11, 80, 1));
assert!(boxes.iter().all(|hit| !hit.contains(2, hit.rect.y)));
}
@ -6725,7 +6820,13 @@ branch = "orchestration/custom-panel"
let rows = list_rows(&app, 80, 6);
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6));
assert!(app.handle_mouse_event(left_click(2, 2)));
assert!(app.handle_mouse_event(left_click(2, 3)));
assert_eq!(
app.selected_row,
Some(PanelRowKey::Ticket("TICKET-2".into()))
);
app.selected_row = None;
assert!(app.handle_mouse_event(left_click(2, 4)));
assert_eq!(
app.selected_row,
Some(PanelRowKey::Ticket("TICKET-2".into()))
@ -6787,7 +6888,7 @@ branch = "orchestration/custom-panel"
let rows = list_rows(&app, 80, 6);
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6));
assert!(app.handle_mouse_event(left_click(2, 2)));
assert!(app.handle_mouse_event(left_click(2, 4)));
assert_eq!(
app.selected_row,
Some(PanelRowKey::Ticket("TICKET-2".into()))
@ -7221,15 +7322,39 @@ branch = "orchestration/custom-panel"
}
#[test]
fn panel_ticket_rows_use_aligned_columns_before_title() {
let review_row = panel_test_ticket_row(
fn panel_ticket_rows_render_state_title_then_detail_line() {
let row = panel_test_ticket_row(
"00001KTX1QMG9",
"Workspace panel composer targets",
ActionPriority::ActiveWork,
NextUserAction::Wait,
"inprogress",
);
let ready_row = panel_test_ticket_row(
let [title, detail] = panel_row_lines(&row, true, 160);
let title_line = plain_line(&title);
let detail_line = plain_line(&detail);
let state_start = 2;
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
let row_id = row.ticket.as_ref().unwrap().id.as_str();
assert!(title_line.starts_with("| "));
assert!(detail_line.starts_with("| "));
assert!(!title_line.starts_with(""));
assert!(!title_line.contains(row_id));
assert_eq!(display_column(&title_line, "inprogress"), state_start);
assert_eq!(
display_column(&title_line, "Workspace panel composer targets"),
title_start
);
assert!(detail_line.contains(row_id));
assert!(detail_line.contains("Gate: clear"));
assert!(detail_line.contains("Action: Wait"));
}
#[test]
fn panel_ticket_non_selected_rows_align_with_selected_marker_space() {
let row = panel_test_ticket_row(
"00001KTTB479X",
"Long Ticket title that should be rendered after short columns",
ActionPriority::ReadyForQueue,
@ -7237,53 +7362,62 @@ branch = "orchestration/custom-panel"
"ready",
);
let review_line = plain_line(&panel_row_line(&review_row, true, 160));
let ready_line = plain_line(&panel_row_line(&ready_row, false, 160));
let [title, detail] = panel_row_lines(&row, false, 160);
let title_line = plain_line(&title);
let detail_line = plain_line(&detail);
let state_start = 2;
let id_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
let title_start = id_start + TICKET_ID_COLUMN_WIDTH + 1;
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
assert!(!review_line.starts_with("▶ Workspace panel composer targets"));
assert_eq!(display_column(&review_line, "inprogress"), state_start);
assert_eq!(display_column(&ready_line, "ready"), state_start);
let review_id = review_row.ticket.as_ref().unwrap().id.as_str();
let ready_id = ready_row.ticket.as_ref().unwrap().id.as_str();
assert_eq!(review_id.width(), TICKET_ID_COLUMN_WIDTH);
assert_eq!(ready_id.width(), TICKET_ID_COLUMN_WIDTH);
assert_eq!(display_column(&review_line, review_id), id_start);
assert_eq!(display_column(&ready_line, ready_id), id_start);
assert!(title_line.starts_with(" ready"));
assert!(detail_line.starts_with(" 00001KTTB479X"));
assert_eq!(display_column(&title_line, "ready"), state_start);
assert_eq!(
display_column(&review_line, "Workspace panel composer targets"),
title_start
);
assert_eq!(
display_column(&ready_line, "Long Ticket title"),
display_column(&title_line, "Long Ticket title"),
title_start
);
}
#[test]
fn panel_ticket_title_truncates_after_stable_columns() {
fn panel_ticket_title_truncates_after_state_column() {
let row = panel_test_ticket_row(
"00001KTTB479X",
"Very long Ticket title that should truncate only after the aligned short columns",
"Very long Ticket title that should truncate only after the state column",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
);
let line = plain_line(&panel_row_line(&row, false, 58));
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1 + TICKET_ID_COLUMN_WIDTH + 1;
let [title, detail] = panel_row_lines(&row, false, 42);
let title_line = plain_line(&title);
let detail_line = plain_line(&detail);
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1;
assert_eq!(line.width(), 58);
let row_id = row.ticket.as_ref().unwrap().id.as_str();
assert_eq!(row_id.width(), TICKET_ID_COLUMN_WIDTH);
assert_eq!(
display_column(&line, row_id),
title_start - TICKET_ID_COLUMN_WIDTH - 1
assert_eq!(title_line.width(), 42);
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
assert!(title_line.ends_with('…'));
assert_eq!(detail_line.width(), 42);
assert!(detail_line.starts_with(" 00001KTTB479X · Gate: clear"));
assert!(detail_line.ends_with('…'));
}
#[test]
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
let mut row = panel_test_ticket_row(
"00001WAITING",
"Ready but gated",
ActionPriority::Background,
NextUserAction::Wait,
"ready",
);
assert_eq!(display_column(&line, "Very long Ticket"), title_start);
assert!(line.ends_with('…'));
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
let [_title, detail] = panel_row_lines(&row, true, 160);
let detail_line = plain_line(&detail);
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
assert!(detail_line.contains("Action: queue disabled"));
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
}
#[test]

View File

@ -190,14 +190,12 @@ pub(crate) enum PanelRowKind {
Planning,
Ticket,
Review,
Blocked,
ActiveWork,
Pod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ActionPriority {
UserReply,
ReadyForQueue,
ActiveWork,
Background,
@ -208,7 +206,6 @@ pub(crate) enum NextUserAction {
Clarify,
Queue,
Close,
Edit,
Wait,
OpenPod,
}
@ -219,7 +216,6 @@ impl NextUserAction {
Self::Clarify => "Clarify",
Self::Queue => "Queue",
Self::Close => "Close",
Self::Edit => "Edit",
Self::Wait => "Wait",
Self::OpenPod => "Open",
}
@ -705,7 +701,8 @@ fn derive_ticket_state(
relation_blockers: &[TicketRelationBlocker],
) -> DerivedTicketState {
if !relation_blockers.is_empty() {
let blockers = relation_blockers
let shown_blockers = relation_blockers.iter().take(3).count();
let mut blockers = relation_blockers
.iter()
.take(3)
.map(|blocker| {
@ -718,15 +715,31 @@ fn derive_ticket_state(
})
.collect::<Vec<_>>()
.join(", ");
let remaining_blockers = relation_blockers.len().saturating_sub(shown_blockers);
if remaining_blockers > 0 {
blockers.push_str(&format!(" (+{remaining_blockers} more)"));
}
let waiting_reason = format!("waiting for {blockers}");
return DerivedTicketState {
kind: PanelRowKind::Blocked,
priority: ActionPriority::UserReply,
action: Some(NextUserAction::Edit),
disabled_reason: Some(
"Unresolved Ticket relation blocks queueing; resolve dependency/blocker before ready -> queued."
.to_string(),
),
key_hint: Some("Open the Ticket relation diagnostics before queueing".to_string()),
kind: match summary.workflow_state {
TicketWorkflowState::Planning => PanelRowKind::Planning,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
PanelRowKind::ActiveWork
}
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
TicketWorkflowState::Ready => PanelRowKind::Ticket,
},
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
ActionPriority::ActiveWork
}
_ => ActionPriority::Background,
},
action: Some(NextUserAction::Wait),
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
blocked_reason: Some(blockers),
};
}
@ -1105,7 +1118,7 @@ mod tests {
}
#[test]
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_blocked() {
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@ -1134,14 +1147,14 @@ mod tests {
.find(|row| row.title == "Ready Blocked By Relation")
.unwrap();
assert_eq!(row.kind, PanelRowKind::Blocked);
assert_eq!(row.next_action, Some(NextUserAction::Edit));
assert_eq!(row.priority, ActionPriority::UserReply);
assert_eq!(row.kind, PanelRowKind::Ticket);
assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_eq!(row.priority, ActionPriority::Background);
assert!(
row.disabled_reason
.as_deref()
.unwrap()
.contains("Unresolved Ticket relation")
.contains("Queue disabled: waiting for")
);
assert!(
row.ticket