use std::path::PathBuf;
use chrono::DateTime;
use tokio::runtime::Handle;
use travelagent_core::config::CommentTypeConfig;
use travelagent_core::forge::{
MergeableStatus, PrId, PrMetadata, PrState, RemoteComment, ReviewThread,
};
use travelagent_core::model::{DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin, LineSide};
use travelagent_core::vcs::CommitInfo;
use crate::app::App;
use crate::theme::Theme;
const DEMO_OWNER: &str = "wanderlust-labs";
const DEMO_REPO: &str = "travelagent";
const DEMO_PR_NUMBER: u64 = 42;
const DEMO_TITLE: &str = "feat(greetings): add pun-puns to the travel agent greeting module";
const DEMO_HEAD_SHA: &str = "c0ffee1234567890abcdef1234567890abcdef12";
pub fn create_demo_app(
theme: Theme,
comment_types_config: Option<Vec<CommentTypeConfig>>,
output_to_stdout: bool,
runtime_handle: Handle,
) -> anyhow::Result<App> {
let diff_files = demo_diff_files();
let metadata = demo_metadata();
let commits = demo_commits();
let comments = demo_comments();
let threads = demo_threads();
let mut app = App::new_remote(
theme,
comment_types_config,
output_to_stdout,
diff_files,
metadata.title.clone(),
DEMO_PR_NUMBER,
DEMO_OWNER,
DEMO_REPO,
runtime_handle,
None,
PrId {
owner: DEMO_OWNER.to_string(),
repo: DEMO_REPO.to_string(),
number: DEMO_PR_NUMBER,
},
)?;
{
let r = app
.remote_mut()
.expect("new_remote produces remote-mode App");
r.pr_metadata = Some(metadata);
r.pr_commits = commits;
r.remote_comments = comments;
r.review_threads = threads;
r.forge_host = None;
}
Ok(app)
}
const DEMO_EPOCH: i64 = 1_714_300_000;
fn ts(offset_secs: i64) -> chrono::DateTime<chrono::Utc> {
DateTime::from_timestamp(DEMO_EPOCH + offset_secs, 0)
.expect("demo timestamp within chrono range")
}
fn demo_metadata() -> PrMetadata {
PrMetadata {
title: DEMO_TITLE.to_string(),
body: "## Summary\n\n\
The travel agent module has been entirely too professional. This PR \
teaches it to greet travelers with destination-appropriate puns so \
every trip starts with a smile.\n\n\
## What changed\n\n\
- Added `pun_for_destination` to the greeting module.\n\
- Routed all greetings through the pun engine by default.\n\
- Updated the welcome banner to reflect the new tone.\n\n\
## Test plan\n\n\
- [x] `cargo test --package travelagent-core greetings`\n\
- [x] Manual: book a flight to Paris, observe \"Eiffel for you\" greeting\n\
- [ ] QA: confirm puns do not fire for stoic enterprise clients"
.to_string(),
author: "ada-explorer".to_string(),
state: PrState::Open,
base_branch: "main".to_string(),
head_branch: "feat/greeting-puns".to_string(),
head_sha: DEMO_HEAD_SHA.to_string(),
created_at: ts(0),
mergeable: Some(MergeableStatus::Clean),
is_draft: false,
}
}
fn demo_commits() -> Vec<CommitInfo> {
vec![
CommitInfo {
id: "a1b2c3d4e5f6789012345678901234567890abcd".to_string(),
short_id: "a1b2c3d".to_string(),
branch_name: Some("feat/greeting-puns".to_string()),
summary: "feat(greetings): introduce destination-aware pun engine".to_string(),
body: Some(
"Adds pun_for_destination() plus a small lookup table keyed \
by IATA-style codes."
.to_string(),
),
author: "ada-explorer".to_string(),
time: ts(60),
},
CommitInfo {
id: "b2c3d4e5f6789012345678901234567890abcdef".to_string(),
short_id: "b2c3d4e".to_string(),
branch_name: None,
summary: "refactor: route welcome banner through pun engine".to_string(),
body: None,
author: "ada-explorer".to_string(),
time: ts(3_600),
},
CommitInfo {
id: DEMO_HEAD_SHA.to_string(),
short_id: "c0ffee1".to_string(),
branch_name: None,
summary: "test: lock greeting fixtures so the puns don't pun themselves".to_string(),
body: None,
author: "ada-explorer".to_string(),
time: ts(7_200),
},
]
}
fn demo_diff_files() -> Vec<DiffFile> {
vec![greetings_diff_file(), banner_diff_file()]
}
fn greetings_diff_file() -> DiffFile {
let hunk_one = DiffHunk {
header: "@@ -1,7 +1,20 @@".to_string(),
old_start: 1,
old_count: 7,
new_start: 1,
new_count: 20,
lines: vec![
context_line(
"//! Greeting helpers for the travel agent.",
Some(1),
Some(1),
),
context_line("", Some(2), Some(2)),
context_line("use crate::config::Tone;", Some(3), Some(3)),
addition_line("use crate::puns::pun_for_destination;", None, Some(4)),
context_line("", Some(4), Some(5)),
deletion_line("pub fn welcome(name: &str) -> String {", Some(5), None),
deletion_line(" format!(\"Welcome, {name}.\")", Some(6), None),
deletion_line("}", Some(7), None),
addition_line(
"pub fn welcome(name: &str, destination: &str) -> String {",
None,
Some(6),
),
addition_line(
" let pun = pun_for_destination(destination);",
None,
Some(7),
),
addition_line(" if pun.is_empty() {", None, Some(8)),
addition_line(
" format!(\"Welcome, {name}. Heading to {destination}?\")",
None,
Some(9),
),
addition_line(" } else {", None, Some(10)),
addition_line(
" format!(\"Welcome, {name}. {pun}\")",
None,
Some(11),
),
addition_line(" }", None, Some(12)),
addition_line("}", None, Some(13)),
addition_line("", None, Some(14)),
addition_line("pub fn farewell(name: &str) -> String {", None, Some(15)),
addition_line(
" format!(\"Safe travels, {name}. Don't forget your passport!\")",
None,
Some(16),
),
addition_line("}", None, Some(17)),
],
};
let hunk_two = DiffHunk {
header: "@@ -40,6 +53,18 @@".to_string(),
old_start: 40,
old_count: 6,
new_start: 53,
new_count: 18,
lines: vec![
context_line(" #[test]", Some(40), Some(53)),
context_line(" fn welcome_uses_name() {", Some(41), Some(54)),
deletion_line(
" assert_eq!(welcome(\"Ada\"), \"Welcome, Ada.\");",
Some(42),
None,
),
addition_line(
" let greeting = welcome(\"Ada\", \"PAR\");",
None,
Some(55),
),
addition_line(
" assert!(greeting.contains(\"Ada\"));",
None,
Some(56),
),
addition_line(
" assert!(greeting.contains(\"Eiffel\"));",
None,
Some(57),
),
context_line(" }", Some(43), Some(58)),
context_line("", Some(44), Some(59)),
addition_line(" #[test]", None, Some(60)),
addition_line(" fn farewell_reminds_passport() {", None, Some(61)),
addition_line(
" assert!(farewell(\"Ada\").contains(\"passport\"));",
None,
Some(62),
),
addition_line(" }", None, Some(63)),
addition_line("", None, Some(64)),
context_line(" #[test]", Some(45), Some(65)),
context_line(" fn tone_defaults_to_warm() {", Some(46), Some(66)),
],
};
DiffFile {
old_path: Some(PathBuf::from("crates/travelagent-core/src/greetings.rs")),
new_path: Some(PathBuf::from("crates/travelagent-core/src/greetings.rs")),
status: FileStatus::Modified,
hunks: vec![hunk_one, hunk_two],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
fn banner_diff_file() -> DiffFile {
let hunk = DiffHunk {
header: "@@ -1,3 +1,4 @@".to_string(),
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 4,
lines: vec![
context_line("# Travel Agent", Some(1), Some(1)),
context_line("", Some(2), Some(2)),
deletion_line(
"Your friendly neighborhood booking assistant.",
Some(3),
None,
),
addition_line(
"Your friendly neighborhood booking assistant -- now with puns.",
None,
Some(3),
),
addition_line("Because every trip deserves a warm-up joke.", None, Some(4)),
],
};
DiffFile {
old_path: Some(PathBuf::from("crates/travelagent-core/assets/banner.md")),
new_path: Some(PathBuf::from("crates/travelagent-core/assets/banner.md")),
status: FileStatus::Modified,
hunks: vec![hunk],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
fn context_line(content: &str, old: Option<u32>, new: Option<u32>) -> DiffLine {
DiffLine {
origin: LineOrigin::Context,
content: content.to_string(),
old_lineno: old,
new_lineno: new,
highlighted_spans: None,
}
}
fn addition_line(content: &str, old: Option<u32>, new: Option<u32>) -> DiffLine {
DiffLine {
origin: LineOrigin::Addition,
content: content.to_string(),
old_lineno: old,
new_lineno: new,
highlighted_spans: None,
}
}
fn deletion_line(content: &str, old: Option<u32>, new: Option<u32>) -> DiffLine {
DiffLine {
origin: LineOrigin::Deletion,
content: content.to_string(),
old_lineno: old,
new_lineno: new,
highlighted_spans: None,
}
}
fn demo_comments() -> Vec<RemoteComment> {
vec![
RemoteComment {
id: 1001,
author: "sam-reviewer".to_string(),
body: "Love the direction here. Question: what happens when the destination \
code isn't in the lookup table? I assume we just skip the pun."
.to_string(),
path: None,
line: None,
side: None,
created_at: ts(300),
in_reply_to: None,
},
RemoteComment {
id: 1002,
author: "ada-explorer".to_string(),
body: "Exactly — `pun_for_destination` returns an empty string for unknown \
codes and the caller falls through to the plain greeting."
.to_string(),
path: None,
line: None,
side: None,
created_at: ts(900),
in_reply_to: Some(1001),
},
RemoteComment {
id: 2001,
author: "jules-qa".to_string(),
body: "Nit: this could be a `let Some(pun) = ... else { return ... }` \
to flatten the branch, but fine as-is."
.to_string(),
path: Some("crates/travelagent-core/src/greetings.rs".to_string()),
line: Some(8),
side: Some(LineSide::New),
created_at: ts(1_500),
in_reply_to: None,
},
RemoteComment {
id: 2002,
author: "sam-reviewer".to_string(),
body: "Can we keep the tagline punctuation consistent with the rest of \
the README? Period at the end, no em-dash."
.to_string(),
path: Some("crates/travelagent-core/assets/banner.md".to_string()),
line: Some(3),
side: Some(LineSide::New),
created_at: ts(2_100),
in_reply_to: None,
},
]
}
fn demo_threads() -> Vec<ReviewThread> {
vec![
ReviewThread {
id: "thread-greetings-8".to_string(),
is_resolved: true,
root_comment_id: 2001,
},
ReviewThread {
id: "thread-banner-3".to_string(),
is_resolved: false,
root_comment_id: 2002,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
fn build() -> App {
create_demo_app(
Theme::dark(),
None,
false,
crate::test_support::runtime_handle(),
)
.expect("demo app builds")
}
#[test]
fn demo_app_has_non_empty_diff_files() {
let app = build();
assert!(
!app.diff_files.is_empty(),
"demo app should have at least one diff file"
);
for file in &app.diff_files {
assert!(
!file.hunks.is_empty(),
"every demo diff file should have hunks"
);
for hunk in &file.hunks {
assert!(
!hunk.lines.is_empty(),
"every demo hunk should have diff lines"
);
}
}
}
#[test]
fn demo_app_has_resolved_and_unresolved_threads_tied_to_real_comments() {
let app = build();
let r = app.remote().expect("demo app is in remote mode");
let resolved_count = r.review_threads.iter().filter(|t| t.is_resolved).count();
let unresolved_count = r.review_threads.iter().filter(|t| !t.is_resolved).count();
assert!(
resolved_count >= 1,
"demo app should have at least one resolved thread"
);
assert!(
unresolved_count >= 1,
"demo app should have at least one unresolved thread"
);
for thread in &r.review_threads {
assert!(
r.remote_comments
.iter()
.any(|c| c.id == thread.root_comment_id),
"thread {} points at comment {} which doesn't exist",
thread.id,
thread.root_comment_id
);
}
}
#[test]
fn demo_app_reports_remote_diff_source_and_stamped_refresh_time() {
let app = build();
assert!(
matches!(
app.diff_source,
crate::app::DiffSource::Remote { pr_number, .. } if pr_number == DEMO_PR_NUMBER
),
"demo diff_source must be Remote with the demo PR number: {:?}",
app.diff_source
);
assert!(
app.remote()
.expect("demo app is in remote mode")
.last_refreshed_at
.is_some(),
"new_remote stamps last_refreshed_at so the status bar has something to render"
);
}
#[test]
fn demo_app_has_no_forge_wired() {
let app = build();
let r = app.remote().expect("demo app is in remote mode");
assert!(
r.forge.is_none(),
"demo app must not have a forge backend wired (no network state)"
);
assert!(
r.pr_metadata.is_some(),
"demo app should have mock PR metadata populated"
);
}
}