use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::entities::profile::CharacterNames;
use crate::features::demo;
use crate::screens::chat::ChatScreen;
use crate::screens::chat_list::ChatListScreen;
use crate::screens::self_model::SelfModelScreen;
use crate::screens::settings::SettingsScreen;
use crate::shared::config::{NoteOrder, Theme};
use crate::shared::i18n::{Lang, locale};
use crate::shared::server::{ServerStatus, ServerStatuses};
use crate::shared::shot::{self, ShotFrame};
use crate::shared::theme::Palette;
pub const SHOT_W: u16 = 116;
pub const HERO_H: u16 = 44;
pub const PANEL_H: u16 = 33;
pub const THEMES: [Theme; 2] = [Theme::Dark, Theme::Light];
fn theme_slug(theme: Theme) -> &'static str {
match theme {
Theme::Dark => "dark",
Theme::Light => "light",
Theme::Auto => unreachable!("Auto theme is not capturable"),
}
}
fn ready_statuses() -> ServerStatuses {
ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
}
}
fn capture(
screen_id: &str,
theme: Theme,
height: u16,
draw: impl FnOnce(&mut ratatui::Frame),
) -> ShotFrame {
let mut term = Terminal::new(TestBackend::new(SHOT_W, height)).unwrap();
term.draw(draw).unwrap();
shot::capture(
term.backend().buffer(),
&Palette::for_theme(theme),
screen_id,
theme_slug(theme),
"en",
)
}
pub fn chat_frame(theme: Theme) -> ShotFrame {
let mut screen = ChatScreen::new();
let mut config = crate::shared::config::AppConfig::default();
config.interface.theme = theme;
config.interface.language = Lang::En;
screen.set_settings(
config,
Vec::new(),
Vec::new(),
Default::default(),
Vec::new(),
);
screen.set_server_status(ready_statuses());
screen.activate_chat(
demo::chat_id(),
demo::CHAT_TITLE.into(),
&demo::showcase_messages(),
demo::INPUT_DRAFT,
demo::feed_view(),
None,
None,
);
capture("chat", theme, HERO_H, |f| screen.render(f))
}
pub fn list_frame(theme: Theme) -> ShotFrame {
let mut screen = ChatListScreen::new(
demo::chat_summaries(),
Some(demo::chat_id()),
Palette::for_theme(theme),
locale(Lang::En),
);
capture("chat-list", theme, PANEL_H, |f| screen.render(f))
}
pub fn settings_frame(theme: Theme, tools: bool) -> ShotFrame {
let mut screen =
SettingsScreen::new(demo::app_config(theme), vec![demo::profile()], Vec::new());
screen.set_server_statuses(ready_statuses());
if tools {
for _ in 0..2 {
screen.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
}
let id = if tools {
"settings-tools"
} else {
"settings-model"
};
capture(id, theme, PANEL_H, |f| screen.render(f))
}
pub fn self_model_frame(theme: Theme) -> ShotFrame {
let mut screen = SelfModelScreen::new(
Some(demo::self_model()),
CharacterNames::default(),
Palette::for_theme(theme),
locale(Lang::En),
NoteOrder::default(),
);
capture("self-model", theme, PANEL_H, |f| screen.render(f))
}
pub fn all_frames(theme: Theme) -> Vec<ShotFrame> {
vec![
chat_frame(theme),
list_frame(theme),
settings_frame(theme, false),
settings_frame(theme, true),
self_model_frame(theme),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
fn frame_text(frame: &ShotFrame) -> String {
frame
.rows
.iter()
.map(|row| row.iter().map(|c| c.s.as_str()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn dumps_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/screenshots/dumps")
}
fn dump_name(frame: &ShotFrame) -> String {
format!("{}-{}-{}.json", frame.screen, frame.theme, frame.locale)
}
const REGEN_HINT: &str = "regenerate: `cargo test dump_demo_frames -- --ignored`, then `python tools/screenshots.py`, and commit both";
#[test]
fn frames_are_deterministic() {
for theme in THEMES {
let a: Vec<String> = all_frames(theme)
.iter()
.map(|f| serde_json::to_string(f).unwrap())
.collect();
let b: Vec<String> = all_frames(theme)
.iter()
.map(|f| serde_json::to_string(f).unwrap())
.collect();
assert_eq!(a, b, "{theme:?} frames are not deterministic");
}
}
#[test]
fn frame_rows_cover_the_grid() {
for frame in all_frames(Theme::Dark) {
assert_eq!(frame.rows.len(), frame.height as usize, "{}", frame.screen);
for (y, row) in frame.rows.iter().enumerate() {
let total: u16 = row.iter().map(|c| c.w as u16).sum();
assert_eq!(
total, frame.width,
"{} row {y} does not cover the grid",
frame.screen
);
}
}
}
#[test]
fn frames_show_their_showcase() {
#[rustfmt::skip]
let needles = |screen: &str| -> &'static [&'static str] {
match screen {
"chat" => &["note_save", "Q5_K_M", "sizing", "sweet spot", "How much context?", "KV headroom", "1B draft"],
"chat-list" => &["Speculative", "Dolomites", "Mermaid", "First week with mindfork"],
"settings-model" => &["llama-server.exe", "16384", "draft-simple", "gemma-4-1B"],
"settings-tools" => &["Agentic loop", "Wasmer sandbox", "Web search"],
"self-model" => &["Assistant", "reproducible", "User", "methodical", "receipts beat repetition"],
other => panic!("no needles for {other}"),
}
};
for frame in all_frames(Theme::Dark) {
let text = frame_text(&frame);
if matches!(frame.screen.as_str(), "chat" | "chat-list") {
assert!(
text.contains(demo::CHAT_TITLE),
"{}: title missing",
frame.screen
);
}
for needle in needles(&frame.screen) {
assert!(
text.contains(needle),
"{}: {needle:?} is not on screen:\n{text}",
frame.screen
);
}
}
}
#[test]
fn committed_dumps_match_the_code() {
let root = dumps_dir();
for theme in THEMES {
for frame in all_frames(theme) {
let name = dump_name(&frame);
let committed = fs::read_to_string(root.join(&name)).unwrap_or_else(|e| {
panic!("{name}: cannot read the committed dump ({e}) — {REGEN_HINT}")
});
let committed = committed.replace("\r\n", "\n");
let fresh = serde_json::to_string_pretty(&frame).unwrap();
if committed.trim_end() != fresh.trim_end() {
let line = committed
.lines()
.zip(fresh.lines())
.position(|(a, b)| a != b)
.map(|i| i + 1);
panic!(
"{name} drifted from the code (first differing line: {line:?}) — {REGEN_HINT}"
);
}
}
}
}
#[test]
#[ignore = "writes assets/screenshots/dumps/"]
fn dump_demo_frames() {
let root = dumps_dir();
fs::create_dir_all(&root).unwrap();
for theme in THEMES {
for frame in all_frames(theme) {
let name = dump_name(&frame);
let json = serde_json::to_string_pretty(&frame).unwrap();
fs::write(root.join(&name), json).unwrap();
eprintln!("wrote {name}");
}
}
}
}