use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use parking_lot::RwLock;
use regex::Regex;
use serde_json::Value;
pub mod template;
pub mod layout;
pub mod inheritance;
#[derive(Debug, thiserror::Error)]
pub enum ViewError {
#[error("模板文件未找到: {0}")]
TemplateNotFound(String),
#[error("模板语法错误: {0}")]
SyntaxError(String),
#[error("模板渲染错误: {0}")]
RenderError(String),
#[error("IO 错误: {0}")]
IoError(#[from] std::io::Error),
}
pub type ViewData = HashMap<String, Value>;
pub type ContentFilter = Arc<dyn Fn(&str) -> String + Send + Sync>;
pub type TemplateFn = Arc<dyn Fn(&[Value]) -> Result<Value, ViewError> + Send + Sync>;
#[derive(Debug, Clone)]
pub struct ViewConfig {
pub view_path: PathBuf,
pub view_suffix: String,
pub view_depr: String,
pub tpl_begin: String,
pub tpl_end: String,
pub taglib_begin: String,
pub taglib_end: String,
pub default_filter: String,
pub layout_on: bool,
pub layout_name: String,
pub layout_item: String,
pub tpl_var_identify: String,
}
impl Default for ViewConfig {
fn default() -> Self {
Self {
view_path: PathBuf::from("view"),
view_suffix: "html".to_string(),
view_depr: "/".to_string(),
tpl_begin: "{".to_string(),
tpl_end: "}".to_string(),
taglib_begin: "{".to_string(),
taglib_end: "}".to_string(),
default_filter: "htmlentities".to_string(),
layout_on: false,
layout_name: "layout".to_string(),
layout_item: "{__CONTENT__}".to_string(),
tpl_var_identify: "array".to_string(),
}
}
}
pub trait TemplateEngine: Send + Sync {
fn exists(&self, template: &str) -> bool;
fn fetch(&self, template: &str, data: &ViewData) -> Result<String, ViewError>;
fn display(&self, content: &str, data: &ViewData) -> Result<String, ViewError>;
fn set_config(&mut self, config: ViewConfig);
fn get_config(&self, name: &str) -> Option<Value>;
fn as_any(&self) -> &dyn std::any::Any;
}
pub struct SimpleTemplateEngine {
config: RwLock<ViewConfig>,
functions: RwLock<HashMap<String, TemplateFn>>,
}
impl SimpleTemplateEngine {
pub fn new(config: ViewConfig) -> Self {
let mut functions = HashMap::new();
register_builtin_functions(&mut functions);
Self {
config: RwLock::new(config),
functions: RwLock::new(functions),
}
}
pub fn register_function(&self, name: &str, func: TemplateFn) {
self.functions.write().insert(name.to_string(), func);
}
pub fn parse_template_path(&self, template: &str) -> PathBuf {
let config = self.config.read();
let view_path = &config.view_path;
let suffix = &config.view_suffix;
if template.is_empty() {
return view_path.join(format!("index.{}", suffix));
}
if let Some(stripped) = template.strip_prefix('/') {
let mut path = PathBuf::from(stripped);
if path.extension().is_none() {
path = path.with_extension(suffix);
}
return path;
}
if let Some(at_pos) = template.find('@') {
let app = &template[..at_pos];
let tpl = &template[at_pos + 1..];
let mut path = PathBuf::from(app);
path.push("view");
path.push(tpl);
if path.extension().is_none() {
path = path.with_extension(suffix);
}
return path;
}
let mut path = view_path.join(template);
if path.extension().is_none() {
path = path.with_extension(suffix);
}
path
}
fn render_content(&self, content: &str, data: &ViewData) -> Result<String, ViewError> {
let (content, literals) = self.extract_literals(content);
let config = self.config.read().clone();
let content = template::render_control_flow(&content, data, &config, |c, d| {
self.render_content(c, d)
})?;
let content = self.parse_tags(&content, data)?;
let content = self.restore_literals(&content, &literals);
Ok(content)
}
fn extract_literals(&self, content: &str) -> (String, Vec<String>) {
let config = self.config.read();
let begin = &config.tpl_begin;
let end = &config.tpl_end;
let literal_open = format!("{}literal{}", begin, end);
let literal_close = format!("{}/literal{}", begin, end);
let mut result = String::with_capacity(content.len());
let mut literals = Vec::new();
let mut remaining = content;
loop {
if let Some(open_pos) = remaining.find(&literal_open) {
result.push_str(&remaining[..open_pos]);
let after_open = &remaining[open_pos + literal_open.len()..];
if let Some(close_pos) = after_open.find(&literal_close) {
let literal_content = &after_open[..close_pos];
let placeholder = format!("<!--###LITERAL{}###-->", literals.len());
literals.push(literal_content.to_string());
result.push_str(&placeholder);
remaining = &after_open[close_pos + literal_close.len()..];
} else {
result.push_str(&remaining[open_pos..]);
break;
}
} else {
result.push_str(remaining);
break;
}
}
(result, literals)
}
fn restore_literals(&self, content: &str, literals: &[String]) -> String {
let mut result = content.to_string();
for (i, literal) in literals.iter().enumerate() {
let placeholder = format!("<!--###LITERAL{}###-->", i);
result = result.replace(&placeholder, literal);
}
result
}
fn parse_tags(&self, content: &str, data: &ViewData) -> Result<String, ViewError> {
let config = self.config.read();
let begin = regex::escape(&config.tpl_begin);
let end = regex::escape(&config.tpl_end);
let pattern = format!("{}(.*?){}", begin, end);
let re = Regex::new(&pattern).map_err(|e| ViewError::SyntaxError(e.to_string()))?;
let mut result = String::with_capacity(content.len());
let mut last_end = 0;
for caps in re.captures_iter(content) {
let full_match = caps.get(0).expect("正则捕获组 0 必定存在");
let tag_content = caps.get(1).expect("正则捕获组 1 必定存在").as_str();
result.push_str(&content[last_end..full_match.start()]);
let rendered = self.render_tag(tag_content, data)?;
result.push_str(&rendered);
last_end = full_match.end();
}
result.push_str(&content[last_end..]);
Ok(result)
}
fn render_tag(&self, tag: &str, data: &ViewData) -> Result<String, ViewError> {
let tag = tag.trim();
if tag.is_empty() {
return Ok(String::new());
}
let first_char = tag.chars().next().expect("已检查 tag 非空");
match first_char {
'$' => self.render_var_tag(tag, data),
':' => self.render_func_tag(tag, data, false),
'~' => self.render_func_tag(tag, data, true),
'/' => {
Ok(String::new())
}
_ => {
let config = self.config.read();
Ok(format!("{}{}{}", config.tpl_begin, tag, config.tpl_end))
}
}
}
fn render_var_tag(&self, tag: &str, data: &ViewData) -> Result<String, ViewError> {
let expr = &tag[1..];
let (var_expr, filters, ternary) = self.split_var_expr(expr);
let value = self.resolve_var(&var_expr, data);
let value = if let Some(ternary_expr) = &ternary {
self.apply_ternary(&value, ternary_expr)?
} else {
value
};
let value = self.apply_filters(value, &filters)?;
Ok(value_to_string(&value))
}
fn split_var_expr(&self, expr: &str) -> (String, Vec<String>, Option<String>) {
if let Some(pos) = expr.find("??") {
let var = expr[..pos].trim().to_string();
let ternary = expr[pos..].trim().to_string();
return (var, Vec::new(), Some(ternary));
}
let parts: Vec<&str> = expr.split('|').collect();
let var_expr = parts[0].trim().to_string();
if let Some(pos) = var_expr.find('?') {
let var = var_expr[..pos].trim().to_string();
let ternary = var_expr[pos..].trim().to_string();
let filters: Vec<String> = parts[1..]
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
return (var, filters, Some(ternary));
}
let filters: Vec<String> = parts[1..]
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
(var_expr, filters, None)
}
fn resolve_var(&self, expr: &str, data: &ViewData) -> Value {
resolve_var_expr(expr, data)
}
fn apply_ternary(&self, value: &Value, ternary: &str) -> Result<Value, ViewError> {
if let Some(default) = ternary.strip_prefix("??") {
if value.is_null() {
return Ok(parse_literal(default.trim()));
}
return Ok(value.clone());
}
if let Some(default) = ternary.strip_prefix("?:") {
if is_truthy(value) {
return Ok(value.clone());
}
return Ok(parse_literal(default.trim()));
}
if let Some(output) = ternary.strip_prefix("?=") {
if is_truthy(value) {
return Ok(parse_literal(output.trim()));
}
return Ok(Value::Null);
}
if let Some(rest) = ternary.strip_prefix('?') {
if let Some(colon_pos) = rest.find(':') {
let true_val = rest[..colon_pos].trim();
let false_val = rest[colon_pos + 1..].trim();
if is_truthy(value) {
return Ok(parse_literal(true_val));
}
return Ok(parse_literal(false_val));
}
if is_truthy(value) {
return Ok(parse_literal(rest.trim()));
}
return Ok(Value::Null);
}
Ok(value.clone())
}
fn apply_filters(&self, mut value: Value, filters: &[String]) -> Result<Value, ViewError> {
let config = self.config.read();
let default_filter = &config.default_filter;
let has_raw = filters.iter().any(|f| f.starts_with("raw"));
if !has_raw && !default_filter.is_empty() && default_filter != "raw" {
value = apply_builtin_filter(value, default_filter, None)?;
}
for filter in filters {
if filter.starts_with("raw") {
continue;
}
let (filter_name, filter_arg) = if let Some(eq_pos) = filter.find('=') {
(&filter[..eq_pos], Some(filter[eq_pos + 1..].to_string()))
} else if let Some(paren_pos) = filter.find('(') {
(
&filter[..paren_pos],
Some(filter[paren_pos + 1..].trim_end_matches(')').to_string()),
)
} else {
(filter.as_str(), None)
};
value = apply_builtin_filter(value, filter_name.trim(), filter_arg)?;
}
Ok(value)
}
fn render_func_tag(
&self,
tag: &str,
_data: &ViewData,
suppress_output: bool,
) -> Result<String, ViewError> {
let expr = &tag[1..];
let (func_name, args) = parse_func_call(expr)?;
let functions = self.functions.read();
let func = functions
.get(&func_name)
.ok_or_else(|| ViewError::RenderError(format!("未注册的模板函数: {}", func_name)))?;
let result = func(&args)?;
if suppress_output {
return Ok(String::new());
}
Ok(value_to_string(&result))
}
}
impl TemplateEngine for SimpleTemplateEngine {
fn exists(&self, template: &str) -> bool {
let path = self.parse_template_path(template);
path.is_file()
}
fn fetch(&self, template: &str, data: &ViewData) -> Result<String, ViewError> {
let path = self.parse_template_path(template);
if !path.is_file() {
return Err(ViewError::TemplateNotFound(format!(
"{} (解析路径: {})",
template,
path.display()
)));
}
let content = std::fs::read_to_string(&path)?;
let config = self.config.read().clone();
let content = inheritance::apply_inheritance(&content, &config)?;
let content = layout::apply_layout(&content, &config)?;
self.render_content(&content, data)
}
fn display(&self, content: &str, data: &ViewData) -> Result<String, ViewError> {
let config = self.config.read().clone();
let content = inheritance::apply_inheritance(content, &config)?;
let content = layout::apply_layout(&content, &config)?;
self.render_content(&content, data)
}
fn set_config(&mut self, config: ViewConfig) {
*self.config.write() = config;
}
fn get_config(&self, name: &str) -> Option<Value> {
let config = self.config.read();
match name {
"view_path" => Some(Value::String(config.view_path.to_string_lossy().into())),
"view_suffix" => Some(Value::String(config.view_suffix.clone())),
"view_depr" => Some(Value::String(config.view_depr.clone())),
"tpl_begin" => Some(Value::String(config.tpl_begin.clone())),
"tpl_end" => Some(Value::String(config.tpl_end.clone())),
"taglib_begin" => Some(Value::String(config.taglib_begin.clone())),
"taglib_end" => Some(Value::String(config.taglib_end.clone())),
"default_filter" => Some(Value::String(config.default_filter.clone())),
"layout_on" => Some(Value::Bool(config.layout_on)),
"layout_name" => Some(Value::String(config.layout_name.clone())),
"layout_item" => Some(Value::String(config.layout_item.clone())),
"tpl_var_identify" => Some(Value::String(config.tpl_var_identify.clone())),
_ => None,
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub struct View {
data: RwLock<ViewData>,
filter: RwLock<Option<ContentFilter>>,
engine: RwLock<Box<dyn TemplateEngine>>,
}
impl View {
pub fn new(engine: Box<dyn TemplateEngine>) -> Self {
Self {
data: RwLock::new(HashMap::new()),
filter: RwLock::new(None),
engine: RwLock::new(engine),
}
}
pub fn with_default_engine() -> Self {
Self::new(Box::new(SimpleTemplateEngine::new(ViewConfig::default())))
}
pub fn with_config(config: ViewConfig) -> Self {
Self::new(Box::new(SimpleTemplateEngine::new(config)))
}
pub fn assign(&self, name: &str, value: Value) -> &Self {
self.data.write().insert(name.to_string(), value);
self
}
pub fn assign_many(&self, vars: ViewData) -> &Self {
self.data.write().extend(vars);
self
}
pub fn set_filter(&self, filter: ContentFilter) -> &Self {
*self.filter.write() = Some(filter);
self
}
pub fn clear_filter(&self) -> &Self {
*self.filter.write() = None;
self
}
pub fn fetch(&self, template: &str, vars: Option<ViewData>) -> Result<String, ViewError> {
let mut data = self.data.read().clone();
if let Some(vars) = vars {
data.extend(vars);
}
let content = self.engine.read().fetch(template, &data)?;
self.apply_filter(content)
}
pub fn display(&self, content: &str, vars: Option<ViewData>) -> Result<String, ViewError> {
let mut data = self.data.read().clone();
if let Some(vars) = vars {
data.extend(vars);
}
let rendered = self.engine.read().display(content, &data)?;
self.apply_filter(rendered)
}
pub fn exists(&self, template: &str) -> bool {
self.engine.read().exists(template)
}
pub fn get_var(&self, name: &str) -> Option<Value> {
self.data.read().get(name).cloned()
}
pub fn has_var(&self, name: &str) -> bool {
self.data.read().contains_key(name)
}
pub fn clear_vars(&self) -> &Self {
self.data.write().clear();
self
}
pub fn engine(&self) -> parking_lot::RwLockReadGuard<'_, Box<dyn TemplateEngine>> {
self.engine.read()
}
pub fn set_engine(&self, engine: Box<dyn TemplateEngine>) -> &Self {
*self.engine.write() = engine;
self
}
fn apply_filter(&self, content: String) -> Result<String, ViewError> {
if let Some(filter) = self.filter.read().as_ref() {
Ok(filter(&content))
} else {
Ok(content)
}
}
}
pub(super) fn resolve_var_expr(expr: &str, data: &ViewData) -> Value {
let parts: Vec<&str> = expr.split('.').collect();
let mut current = data.get(parts[0]).cloned().unwrap_or(Value::Null);
for part in &parts[1..] {
current = match ¤t {
Value::Object(map) => map.get(*part).cloned().unwrap_or(Value::Null),
Value::Array(arr) => {
if let Ok(idx) = part.parse::<usize>() {
arr.get(idx).cloned().unwrap_or(Value::Null)
} else {
Value::Null
}
}
_ => Value::Null,
};
}
current
}
fn htmlentities(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'"' => result.push_str("""),
'\'' => result.push_str("'"),
_ => result.push(c),
}
}
result
}
pub(super) fn is_truthy(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
Value::String(s) => !s.is_empty() && s != "0",
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
pub(super) fn value_to_string(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::Bool(b) => if *b { "1" } else { "" }.to_string(),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
i.to_string()
} else if let Some(f) = n.as_f64() {
if f == f.trunc() {
format!("{}", f as i64)
} else {
format!("{}", f)
}
} else {
n.to_string()
}
}
Value::String(s) => s.clone(),
Value::Array(a) => serde_json::to_string(a).unwrap_or_default(),
Value::Object(o) => serde_json::to_string(o).unwrap_or_default(),
}
}
pub(super) fn parse_literal(s: &str) -> Value {
let s = s.trim();
if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
|| (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
{
return Value::String(s[1..s.len() - 1].to_string());
}
if let Ok(i) = s.parse::<i64>() {
return Value::Number(i.into());
}
if let Ok(f) = s.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(f) {
return Value::Number(n);
}
}
match s {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
"null" => return Value::Null,
_ => {}
}
Value::String(s.to_string())
}
fn parse_func_call(expr: &str) -> Result<(String, Vec<Value>), ViewError> {
let expr = expr.trim();
if let Some(paren_pos) = expr.find('(') {
let func_name = expr[..paren_pos].trim().to_string();
let args_str = expr[paren_pos + 1..].trim_end_matches(')');
let mut args = Vec::new();
if !args_str.trim().is_empty() {
for arg in split_args(args_str) {
args.push(parse_literal(arg.trim()));
}
}
Ok((func_name, args))
} else {
Ok((expr.to_string(), Vec::new()))
}
}
fn split_args(s: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_single_quote = false;
let mut in_double_quote = false;
for c in s.chars() {
match c {
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
current.push(c);
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
current.push(c);
}
',' if !in_single_quote && !in_double_quote => {
args.push(current.trim().to_string());
current.clear();
}
_ => current.push(c),
}
}
if !current.trim().is_empty() {
args.push(current.trim().to_string());
}
args
}
fn apply_builtin_filter(
value: Value,
filter_name: &str,
arg: Option<String>,
) -> Result<Value, ViewError> {
match filter_name {
"raw" => Ok(value),
"htmlentities" | "htmlspecialchars" => {
Ok(Value::String(htmlentities(&value_to_string(&value))))
}
"upper" | "strtoupper" => Ok(Value::String(value_to_string(&value).to_uppercase())),
"lower" | "strtolower" => Ok(Value::String(value_to_string(&value).to_lowercase())),
"default" => {
if is_truthy(&value) {
Ok(value)
} else {
let default_val = arg.unwrap_or_default();
Ok(parse_literal(&default_val))
}
}
"first" => {
if let Value::Array(arr) = &value {
Ok(arr.first().cloned().unwrap_or(Value::Null))
} else {
Ok(Value::Null)
}
}
"last" => {
if let Value::Array(arr) = &value {
Ok(arr.last().cloned().unwrap_or(Value::Null))
} else {
Ok(Value::Null)
}
}
_ => Err(ViewError::RenderError(format!(
"未知的模板过滤器: {}",
filter_name
))),
}
}
fn register_builtin_functions(functions: &mut HashMap<String, TemplateFn>) {
functions.insert(
"date".to_string(),
Arc::new(|args: &[Value]| -> Result<Value, ViewError> {
let format = args
.first()
.and_then(|v| v.as_str())
.unwrap_or("Y-m-d H:i:s");
let now = chrono::Local::now();
let php_format = php_date_to_chrono(format);
Ok(Value::String(now.format(&php_format).to_string()))
}),
);
functions.insert(
"strtoupper".to_string(),
Arc::new(|args: &[Value]| -> Result<Value, ViewError> {
let s = args.first().map(value_to_string).unwrap_or_default();
Ok(Value::String(s.to_uppercase()))
}),
);
functions.insert(
"strtolower".to_string(),
Arc::new(|args: &[Value]| -> Result<Value, ViewError> {
let s = args.first().map(value_to_string).unwrap_or_default();
Ok(Value::String(s.to_lowercase()))
}),
);
}
fn php_date_to_chrono(php_format: &str) -> String {
let mut result = String::with_capacity(php_format.len() * 2);
let chars = php_format.chars();
for c in chars {
match c {
'Y' => result.push_str("%Y"),
'y' => result.push_str("%y"),
'm' => result.push_str("%m"),
'n' => result.push_str("%-m"),
'd' => result.push_str("%d"),
'j' => result.push_str("%-d"),
'H' => result.push_str("%H"),
'G' => result.push_str("%-H"),
'i' => result.push_str("%M"),
's' => result.push_str("%S"),
'D' => result.push_str("%a"),
'l' => result.push_str("%A"),
'M' => result.push_str("%b"),
'F' => result.push_str("%B"),
'a' => result.push_str("%p"),
'A' => result.push_str("%p"),
'U' => result.push_str("%s"),
_ => {
result.push(c);
}
}
}
result
}
use axum::response::Response;
pub struct ViewFallback {
view: View,
}
impl ViewFallback {
pub fn new(view: View) -> Self {
Self { view }
}
pub fn with_default_engine() -> Self {
Self::new(View::with_default_engine())
}
pub fn with_config(config: ViewConfig) -> Self {
Self::new(View::with_config(config))
}
pub fn render_template(
&self,
template: &str,
vars: Option<ViewData>,
) -> Result<Response, ViewError> {
let content = self.view.fetch(template, vars)?;
Ok(crate::response::respond_html(content))
}
pub fn render_display(
&self,
content: &str,
vars: Option<ViewData>,
) -> Result<Response, ViewError> {
let rendered = self.view.display(content, vars)?;
Ok(crate::response::respond_html(rendered))
}
pub fn render_to_string(
&self,
template: &str,
vars: Option<ViewData>,
) -> Result<String, ViewError> {
self.view.fetch(template, vars)
}
pub fn display_to_string(
&self,
content: &str,
vars: Option<ViewData>,
) -> Result<String, ViewError> {
self.view.display(content, vars)
}
pub fn view(&self) -> &View {
&self.view
}
pub fn assign(&self, name: &str, value: Value) -> &Self {
self.view.assign(name, value);
self
}
pub fn assign_many(&self, vars: ViewData) -> &Self {
self.view.assign_many(vars);
self
}
pub fn clear_vars(&self) -> &Self {
self.view.clear_vars();
self
}
}
pub fn render_template_response(
view: &View,
template: &str,
vars: Option<ViewData>,
) -> Result<Response, ViewError> {
let content = view.fetch(template, vars)?;
Ok(crate::response::respond_html(content))
}
pub fn render_display_response(
view: &View,
content: &str,
vars: Option<ViewData>,
) -> Result<Response, ViewError> {
let rendered = view.display(content, vars)?;
Ok(crate::response::respond_html(rendered))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::Path;
fn make_view() -> View {
View::with_default_engine()
}
fn make_view_with_path(path: &Path) -> View {
let config = ViewConfig {
view_path: path.to_path_buf(),
..Default::default()
};
View::with_config(config)
}
fn make_temp_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"sz_rust_view_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_template(dir: &Path, name: &str, content: &str) {
let path = dir.join(format!("{}.html", name));
std::fs::write(&path, content).unwrap();
}
fn cleanup_dir(dir: &Path) {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn test_assign_single_var() {
let view = make_view();
view.assign("foo", json!("bar"));
assert_eq!(view.get_var("foo"), Some(json!("bar")));
}
#[test]
fn test_assign_multiple_vars() {
let view = make_view();
view.assign("foo", json!("bar"))
.assign("baz", json!("boom"));
assert_eq!(view.get_var("foo"), Some(json!("bar")));
assert_eq!(view.get_var("baz"), Some(json!("boom")));
}
#[test]
fn test_assign_overwrite() {
let view = make_view();
view.assign("foo", json!("bar"));
view.assign("foo", json!("new"));
assert_eq!(view.get_var("foo"), Some(json!("new")));
}
#[test]
fn test_has_var() {
let view = make_view();
assert!(!view.has_var("foo"));
view.assign("foo", json!("bar"));
assert!(view.has_var("foo"));
}
#[test]
fn test_clear_vars() {
let view = make_view();
view.assign("foo", json!("bar"));
view.clear_vars();
assert!(!view.has_var("foo"));
}
#[test]
fn test_assign_many() {
let view = make_view();
let mut vars = ViewData::new();
vars.insert("a".to_string(), json!(1));
vars.insert("b".to_string(), json!(2));
view.assign_many(vars);
assert_eq!(view.get_var("a"), Some(json!(1)));
assert_eq!(view.get_var("b"), Some(json!(2)));
}
#[test]
fn test_display_string_var() {
let view = make_view();
let result = view
.display(
"Hello {$name}!",
Some(ViewData::from([("name".to_string(), json!("World"))])),
)
.unwrap();
assert_eq!(result, "Hello World!");
}
#[test]
fn test_display_with_assign() {
let view = make_view();
view.assign("name", json!("World"));
let result = view.display("Hello {$name}!", None).unwrap();
assert_eq!(result, "Hello World!");
}
#[test]
fn test_display_vars_override_assign() {
let view = make_view();
view.assign("name", json!("Default"));
let result = view
.display(
"Hello {$name}!",
Some(ViewData::from([("name".to_string(), json!("Override"))])),
)
.unwrap();
assert_eq!(result, "Hello Override!");
}
#[test]
fn test_display_no_vars() {
let view = make_view();
let result = view.display("Hello World!", None).unwrap();
assert_eq!(result, "Hello World!");
}
#[test]
fn test_display_missing_var() {
let view = make_view();
let result = view.display("Hello {$name}!", None).unwrap();
assert_eq!(result, "Hello !");
}
#[test]
fn test_display_multiple_vars() {
let view = make_view();
let result = view
.display(
"{$greeting}, {$name}!",
Some(ViewData::from([
("greeting".to_string(), json!("Hello")),
("name".to_string(), json!("World")),
])),
)
.unwrap();
assert_eq!(result, "Hello, World!");
}
#[test]
fn test_display_nested_object() {
let view = make_view();
let result = view
.display(
"Name: {$user.name}",
Some(ViewData::from([(
"user".to_string(),
json!({"name": "Alice", "age": 30}),
)])),
)
.unwrap();
assert_eq!(result, "Name: Alice");
}
#[test]
fn test_display_deep_nested() {
let view = make_view();
let result = view
.display(
"{$a.b.c}",
Some(ViewData::from([(
"a".to_string(),
json!({"b": {"c": "deep"}}),
)])),
)
.unwrap();
assert_eq!(result, "deep");
}
#[test]
fn test_display_array_index() {
let view = make_view();
let result = view
.display(
"{$arr.0}",
Some(ViewData::from([(
"arr".to_string(),
json!(["first", "second"]),
)])),
)
.unwrap();
assert_eq!(result, "first");
}
#[test]
fn test_display_nested_missing() {
let view = make_view();
let result = view
.display(
"{$user.name}",
Some(ViewData::from([("user".to_string(), json!({}))])),
)
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_filter_upper() {
let view = make_view();
let result = view
.display(
"{$name|upper}",
Some(ViewData::from([("name".to_string(), json!("hello"))])),
)
.unwrap();
assert_eq!(result, "HELLO");
}
#[test]
fn test_filter_lower() {
let view = make_view();
let result = view
.display(
"{$name|lower}",
Some(ViewData::from([("name".to_string(), json!("HELLO"))])),
)
.unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_filter_default_with_value() {
let view = make_view();
let result = view
.display(
"{$name|default='N/A'}",
Some(ViewData::from([("name".to_string(), json!("Alice"))])),
)
.unwrap();
assert_eq!(result, "Alice");
}
#[test]
fn test_filter_default_without_value() {
let view = make_view();
let result = view.display("{$name|default='N/A'}", None).unwrap();
assert_eq!(result, "N/A");
}
#[test]
fn test_filter_raw() {
let view = make_view();
let result = view
.display(
"{$name|raw}",
Some(ViewData::from([("name".to_string(), json!("<b>bold</b>"))])),
)
.unwrap();
assert_eq!(result, "<b>bold</b>");
}
#[test]
fn test_filter_default_htmlentities() {
let view = make_view();
let result = view
.display(
"{$name}",
Some(ViewData::from([("name".to_string(), json!("<b>bold</b>"))])),
)
.unwrap();
assert_eq!(result, "<b>bold</b>");
}
#[test]
fn test_filter_chained() {
let view = make_view();
let result = view
.display(
"{$name|upper|lower}",
Some(ViewData::from([("name".to_string(), json!("Hello"))])),
)
.unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_ternary_null_coalescing() {
let view = make_view();
let result = view.display("{$name??'default'}", None).unwrap();
assert_eq!(result, "default");
}
#[test]
fn test_ternary_null_coalescing_with_value() {
let view = make_view();
let result = view
.display(
"{$name??'default'}",
Some(ViewData::from([("name".to_string(), json!("Alice"))])),
)
.unwrap();
assert_eq!(result, "Alice");
}
#[test]
fn test_ternary_falsy_default() {
let view = make_view();
let result = view.display("{$name?:'default'}", None).unwrap();
assert_eq!(result, "default");
}
#[test]
fn test_ternary_truthy_output() {
let view = make_view();
let result = view
.display(
"{$name?='yes'}",
Some(ViewData::from([("name".to_string(), json!("Alice"))])),
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_func_date() {
let view = make_view();
let result = view.display("{:date('Y')}", None).unwrap();
let year: u32 = result.parse().unwrap();
assert!((2000..=2100).contains(&year));
}
#[test]
fn test_func_strtoupper() {
let view = make_view();
let result = view.display("{:strtoupper('hello')}", None).unwrap();
assert_eq!(result, "HELLO");
}
#[test]
fn test_func_no_args() {
let view = make_view();
let result = view.display("{:date()}", None).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_func_suppress_output() {
let view = make_view();
let result = view.display("{~strtoupper('hello')}", None).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_func_unknown() {
let view = make_view();
let result = view.display("{:unknown_func()}", None);
assert!(result.is_err());
}
#[test]
fn test_single_line_comment() {
let view = make_view();
let result = view.display("Hello{//这是注释}World", None).unwrap();
assert_eq!(result, "HelloWorld");
}
#[test]
fn test_block_comment() {
let view = make_view();
let result = view.display("Hello{/*块注释*/}World", None).unwrap();
assert_eq!(result, "HelloWorld");
}
#[test]
fn test_literal_preserves_tags() {
let view = make_view();
let result = view
.display(
"{literal}{$name}{/literal}",
Some(ViewData::from([("name".to_string(), json!("World"))])),
)
.unwrap();
assert_eq!(result, "{$name}");
}
#[test]
fn test_literal_mixed() {
let view = make_view();
let result = view
.display(
"Hello {$name}! {literal}{$raw}{/literal} Bye",
Some(ViewData::from([("name".to_string(), json!("World"))])),
)
.unwrap();
assert_eq!(result, "Hello World! {$raw} Bye");
}
#[test]
fn test_literal_multiple() {
let view = make_view();
let result = view
.display(
"{literal}A{/literal} {$name} {literal}B{/literal}",
Some(ViewData::from([("name".to_string(), json!("X"))])),
)
.unwrap();
assert_eq!(result, "A X B");
}
#[test]
fn test_fetch_template_file() {
let dir = make_temp_dir();
write_template(&dir, "index", "<h1>{$title}</h1>");
let view = make_view_with_path(&dir);
let result = view
.fetch(
"index",
Some(ViewData::from([("title".to_string(), json!("Hello"))])),
)
.unwrap();
assert_eq!(result, "<h1>Hello</h1>");
cleanup_dir(&dir);
}
#[test]
fn test_fetch_not_found() {
let dir = make_temp_dir();
let view = make_view_with_path(&dir);
let result = view.fetch("nonexistent", None);
assert!(matches!(result, Err(ViewError::TemplateNotFound(_))));
cleanup_dir(&dir);
}
#[test]
fn test_exists() {
let dir = make_temp_dir();
write_template(&dir, "index", "content");
let view = make_view_with_path(&dir);
assert!(view.exists("index"));
assert!(!view.exists("nonexistent"));
cleanup_dir(&dir);
}
#[test]
fn test_fetch_with_assign() {
let dir = make_temp_dir();
write_template(&dir, "index", "Name: {$name}");
let view = make_view_with_path(&dir);
view.assign("name", json!("Alice"));
let result = view.fetch("index", None).unwrap();
assert_eq!(result, "Name: Alice");
cleanup_dir(&dir);
}
#[test]
fn test_content_filter() {
let view = make_view();
view.set_filter(Arc::new(|content: &str| content.to_uppercase()));
let result = view.display("hello world", None).unwrap();
assert_eq!(result, "HELLO WORLD");
}
#[test]
fn test_clear_filter() {
let view = make_view();
view.set_filter(Arc::new(|content: &str| content.to_uppercase()));
view.clear_filter();
let result = view.display("hello world", None).unwrap();
assert_eq!(result, "hello world");
}
#[test]
fn test_integer_var() {
let view = make_view();
let result = view
.display(
"Count: {$count}",
Some(ViewData::from([("count".to_string(), json!(42))])),
)
.unwrap();
assert_eq!(result, "Count: 42");
}
#[test]
fn test_boolean_true() {
let view = make_view();
let result = view
.display(
"Flag: {$flag}",
Some(ViewData::from([("flag".to_string(), json!(true))])),
)
.unwrap();
assert_eq!(result, "Flag: 1");
}
#[test]
fn test_boolean_false() {
let view = make_view();
let result = view
.display(
"Flag: {$flag}",
Some(ViewData::from([("flag".to_string(), json!(false))])),
)
.unwrap();
assert_eq!(result, "Flag: ");
}
#[test]
fn test_float_var() {
let view = make_view();
let result = view
.display(
"Float: {$f}",
Some(ViewData::from([("f".to_string(), json!(2.5))])),
)
.unwrap();
assert_eq!(result, "Float: 2.5");
}
#[test]
fn test_float_integer_value() {
let view = make_view();
let result = view
.display(
"Num: {$num}",
Some(ViewData::from([("num".to_string(), json!(3.0))])),
)
.unwrap();
assert_eq!(result, "Num: 3");
}
#[test]
fn test_config_default() {
let config = ViewConfig::default();
assert_eq!(config.view_suffix, "html");
assert_eq!(config.tpl_begin, "{");
assert_eq!(config.tpl_end, "}");
assert_eq!(config.default_filter, "htmlentities");
assert_eq!(config.tpl_var_identify, "array");
assert!(!config.layout_on);
}
#[test]
fn test_config_get_config() {
let engine = SimpleTemplateEngine::new(ViewConfig::default());
assert_eq!(
engine.get_config("view_suffix"),
Some(Value::String("html".to_string()))
);
assert_eq!(
engine.get_config("tpl_begin"),
Some(Value::String("{".to_string()))
);
assert_eq!(engine.get_config("nonexistent"), None);
}
#[test]
fn test_register_custom_function() {
let view = make_view();
if let Some(engine) = view
.engine()
.as_any()
.downcast_ref::<SimpleTemplateEngine>()
{
engine.register_function(
"greet",
Arc::new(|args: &[Value]| {
let name = args.first().and_then(|v| v.as_str()).unwrap_or("World");
Ok(Value::String(format!("Hello, {}!", name)))
}),
);
}
let result = view.display("{:greet('Alice')}", None).unwrap();
assert_eq!(result, "Hello, Alice!");
}
#[test]
fn test_parse_template_path_relative() {
let engine = SimpleTemplateEngine::new(ViewConfig::default());
let path = engine.parse_template_path("index");
assert_eq!(path, PathBuf::from("view/index.html"));
}
#[test]
fn test_parse_template_path_with_extension() {
let engine = SimpleTemplateEngine::new(ViewConfig::default());
let path = engine.parse_template_path("index.html");
assert_eq!(path, PathBuf::from("view/index.html"));
}
#[test]
fn test_parse_template_path_absolute() {
let engine = SimpleTemplateEngine::new(ViewConfig::default());
let path = engine.parse_template_path("/absolute/path");
assert_eq!(path, PathBuf::from("absolute/path.html"));
}
#[test]
fn test_parse_template_path_cross_app() {
let engine = SimpleTemplateEngine::new(ViewConfig::default());
let path = engine.parse_template_path("admin@dashboard");
assert_eq!(path, PathBuf::from("admin/view/dashboard.html"));
}
#[test]
fn test_parse_template_path_empty() {
let engine = SimpleTemplateEngine::new(ViewConfig::default());
let path = engine.parse_template_path("");
assert_eq!(path, PathBuf::from("view/index.html"));
}
#[test]
fn test_htmlentities_basic() {
assert_eq!(htmlentities("<b>"), "<b>");
assert_eq!(htmlentities("\"quote\""), ""quote"");
assert_eq!(htmlentities("'apos'"), "'apos'");
assert_eq!(htmlentities("&"), "&amp;");
}
#[test]
fn test_is_truthy() {
assert!(!is_truthy(&Value::Null));
assert!(!is_truthy(&Value::Bool(false)));
assert!(is_truthy(&Value::Bool(true)));
assert!(!is_truthy(&json!(0)));
assert!(is_truthy(&json!(1)));
assert!(!is_truthy(&json!("")));
assert!(!is_truthy(&json!("0")));
assert!(is_truthy(&json!("hello")));
assert!(!is_truthy(&json!([])));
assert!(is_truthy(&json!([1, 2])));
assert!(!is_truthy(&json!({})));
assert!(is_truthy(&json!({"a": 1})));
}
#[test]
fn test_value_to_string() {
assert_eq!(value_to_string(&Value::Null), "");
assert_eq!(value_to_string(&Value::Bool(true)), "1");
assert_eq!(value_to_string(&Value::Bool(false)), "");
assert_eq!(value_to_string(&json!(42)), "42");
assert_eq!(value_to_string(&json!(2.5)), "2.5");
assert_eq!(value_to_string(&json!(3.0)), "3");
assert_eq!(value_to_string(&json!("hello")), "hello");
}
#[test]
fn test_parse_literal() {
assert_eq!(parse_literal("'string'"), Value::String("string".into()));
assert_eq!(parse_literal("\"double\""), Value::String("double".into()));
assert_eq!(parse_literal("42"), json!(42));
assert_eq!(parse_literal("2.5"), json!(2.5));
assert_eq!(parse_literal("true"), Value::Bool(true));
assert_eq!(parse_literal("false"), Value::Bool(false));
assert_eq!(parse_literal("null"), Value::Null);
}
#[test]
fn test_split_args() {
assert_eq!(split_args("a, b, c"), vec!["a", "b", "c"]);
assert_eq!(split_args("'a,b', c"), vec!["'a,b'", "c"]);
assert_eq!(split_args("\"a,b\", c"), vec!["\"a,b\"", "c"]);
assert_eq!(split_args(""), Vec::<String>::new());
}
#[test]
fn test_parse_func_call() {
let (name, args) = parse_func_call("date('Y')").unwrap();
assert_eq!(name, "date");
assert_eq!(args, vec![Value::String("Y".into())]);
let (name, args) = parse_func_call("now()").unwrap();
assert_eq!(name, "now");
assert!(args.is_empty());
let (name, _args) = parse_func_call("noargs").unwrap();
assert_eq!(name, "noargs");
}
async fn extract_body_string(resp: axum::response::Response) -> String {
use http_body_util::BodyExt;
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[test]
fn test_view_fallback_new() {
let view = View::with_default_engine();
let fallback = ViewFallback::new(view);
assert!(!fallback.view().has_var("any"));
}
#[test]
fn test_view_fallback_with_default_engine() {
let fallback = ViewFallback::with_default_engine();
assert!(!fallback.view().has_var("any"));
}
#[test]
fn test_view_fallback_with_config() {
let config = ViewConfig {
view_suffix: "tpl".to_string(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let view = fallback.view();
let engine = view.engine();
assert_eq!(
engine.get_config("view_suffix"),
Some(Value::String("tpl".to_string()))
);
}
#[test]
fn test_view_fallback_assign() {
let fallback = ViewFallback::with_default_engine();
fallback.assign("name", json!("Alice"));
assert_eq!(fallback.view().get_var("name"), Some(json!("Alice")));
}
#[test]
fn test_view_fallback_assign_many() {
let fallback = ViewFallback::with_default_engine();
let mut vars = ViewData::new();
vars.insert("a".to_string(), json!(1));
vars.insert("b".to_string(), json!(2));
fallback.assign_many(vars);
assert_eq!(fallback.view().get_var("a"), Some(json!(1)));
assert_eq!(fallback.view().get_var("b"), Some(json!(2)));
}
#[test]
fn test_view_fallback_assign_chain() {
let fallback = ViewFallback::with_default_engine();
fallback
.assign("a", json!(1))
.assign("b", json!(2))
.assign("c", json!(3));
assert_eq!(fallback.view().get_var("a"), Some(json!(1)));
assert_eq!(fallback.view().get_var("b"), Some(json!(2)));
assert_eq!(fallback.view().get_var("c"), Some(json!(3)));
}
#[test]
fn test_view_fallback_clear_vars() {
let fallback = ViewFallback::with_default_engine();
fallback.assign("name", json!("Alice"));
assert!(fallback.view().has_var("name"));
fallback.clear_vars();
assert!(!fallback.view().has_var("name"));
}
#[test]
fn test_view_fallback_display_to_string() {
let fallback = ViewFallback::with_default_engine();
let result = fallback
.display_to_string(
"Hello {$name}!",
Some(ViewData::from([("name".to_string(), json!("World"))])),
)
.unwrap();
assert_eq!(result, "Hello World!");
}
#[test]
fn test_view_fallback_display_to_string_with_assign() {
let fallback = ViewFallback::with_default_engine();
fallback.assign("name", json!("Alice"));
let result = fallback.display_to_string("Hello {$name}!", None).unwrap();
assert_eq!(result, "Hello Alice!");
}
#[tokio::test]
async fn test_view_fallback_render_display_response() {
let fallback = ViewFallback::with_default_engine();
let resp = fallback
.render_display(
"<h1>Hello {$name}!</h1>",
Some(ViewData::from([("name".to_string(), json!("World"))])),
)
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let content_type = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(content_type, "text/html; charset=utf-8");
let body = extract_body_string(resp).await;
assert_eq!(body, "<h1>Hello World!</h1>");
}
#[tokio::test]
async fn test_view_fallback_render_display_with_assign() {
let fallback = ViewFallback::with_default_engine();
fallback.assign("title", json!("Report"));
let resp = fallback
.render_display("<title>{$title}</title>", None)
.unwrap();
let body = extract_body_string(resp).await;
assert_eq!(body, "<title>Report</title>");
}
#[tokio::test]
async fn test_view_fallback_render_template_file() {
let dir = make_temp_dir();
write_template(&dir, "pdf_template", "<pdf>{$content}</pdf>");
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let resp = fallback
.render_template(
"pdf_template",
Some(ViewData::from([(
"content".to_string(),
json!("Hello PDF"),
)])),
)
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let content_type = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(content_type, "text/html; charset=utf-8");
let body = extract_body_string(resp).await;
assert_eq!(body, "<pdf>Hello PDF</pdf>");
cleanup_dir(&dir);
}
#[test]
fn test_view_fallback_render_template_not_found() {
let dir = make_temp_dir();
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let result = fallback.render_template("nonexistent", None);
assert!(result.is_err());
match result {
Err(ViewError::TemplateNotFound(_)) => {}
Err(e) => panic!("Expected TemplateNotFound, got: {:?}", e),
Ok(_) => panic!("Expected error, got Ok"),
}
cleanup_dir(&dir);
}
#[test]
fn test_view_fallback_render_to_string() {
let dir = make_temp_dir();
write_template(
&dir,
"excel_template",
"<table><tr><td>{$value}</td></tr></table>",
);
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let html = fallback
.render_to_string(
"excel_template",
Some(ViewData::from([("value".to_string(), json!(42))])),
)
.unwrap();
assert_eq!(html, "<table><tr><td>42</td></tr></table>");
cleanup_dir(&dir);
}
#[test]
fn test_view_fallback_render_to_string_not_found() {
let dir = make_temp_dir();
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let result = fallback.render_to_string("nonexistent", None);
assert!(result.is_err());
cleanup_dir(&dir);
}
#[tokio::test]
async fn test_render_template_response_free_function() {
let dir = make_temp_dir();
write_template(&dir, "report", "<report>{$title}</report>");
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let view = View::with_config(config);
let resp = render_template_response(
&view,
"report",
Some(ViewData::from([("title".to_string(), json!("Monthly"))])),
)
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let content_type = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(content_type, "text/html; charset=utf-8");
let body = extract_body_string(resp).await;
assert_eq!(body, "<report>Monthly</report>");
cleanup_dir(&dir);
}
#[tokio::test]
async fn test_render_display_response_free_function() {
let view = View::with_default_engine();
let resp = render_display_response(
&view,
"<p>{$msg}</p>",
Some(ViewData::from([("msg".to_string(), json!("Hello"))])),
)
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let content_type = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(content_type, "text/html; charset=utf-8");
let body = extract_body_string(resp).await;
assert_eq!(body, "<p>Hello</p>");
}
#[test]
fn test_render_template_response_not_found() {
let dir = make_temp_dir();
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let view = View::with_config(config);
let result = render_template_response(&view, "nonexistent", None);
assert!(result.is_err());
cleanup_dir(&dir);
}
#[test]
fn test_view_fallback_pdf_export_scenario() {
let dir = make_temp_dir();
write_template(
&dir,
"payment_pdf",
r#"<html><body><h1>付款单 {$payment_id}</h1><p>金额: {$amount}</p></body></html>"#,
);
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let html = fallback
.render_to_string(
"payment_pdf",
Some(ViewData::from([
("payment_id".to_string(), json!("PAY-001")),
("amount".to_string(), json!("¥1,234.56")),
])),
)
.unwrap();
assert_eq!(
html,
r#"<html><body><h1>付款单 PAY-001</h1><p>金额: ¥1,234.56</p></body></html>"#
);
cleanup_dir(&dir);
}
#[tokio::test]
async fn test_view_fallback_excel_export_scenario() {
let dir = make_temp_dir();
write_template(
&dir,
"order_excel",
r#"<table><tr><th>订单号</th><th>金额</th></tr><tr><td>{$order_no}</td><td>{$amount}</td></tr></table>"#,
);
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let resp = fallback
.render_template(
"order_excel",
Some(ViewData::from([
("order_no".to_string(), json!("ORD-2026-001")),
("amount".to_string(), json!(99.50)),
])),
)
.unwrap();
let body = extract_body_string(resp).await;
assert!(body.contains("<th>订单号</th>"));
assert!(body.contains("<td>ORD-2026-001</td>"));
assert!(body.contains("<td>99.5</td>"));
cleanup_dir(&dir);
}
#[tokio::test]
async fn test_view_fallback_email_scenario() {
let fallback = ViewFallback::with_default_engine();
let resp = fallback
.render_display(
r#"<html><body><h2>Dear {$name}</h2><p>Your order #{$order_id} has been shipped.</p></body></html>"#,
Some(ViewData::from([
("name".to_string(), json!("Alice")),
("order_id".to_string(), json!(12345)),
])),
)
.unwrap();
let body = extract_body_string(resp).await;
assert!(body.contains("Dear Alice"));
assert!(body.contains("#12345"));
assert!(body.contains("has been shipped"));
}
#[test]
fn test_view_fallback_content_type_header() {
let fallback = ViewFallback::with_default_engine();
let resp = fallback.render_display("<p>test</p>", None).unwrap();
let content_type = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(content_type.starts_with("text/html"));
assert!(content_type.contains("charset=utf-8"));
}
#[test]
fn test_view_fallback_http_status() {
let fallback = ViewFallback::with_default_engine();
let resp = fallback.render_display("<html></html>", None).unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[test]
fn test_view_fallback_with_layout() {
let dir = make_temp_dir();
write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
write_template(&dir, "page", "<p>{$content}</p>");
let config = ViewConfig {
view_path: dir.clone(),
layout_on: true,
layout_name: "layout".to_string(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let html = fallback
.render_to_string(
"page",
Some(ViewData::from([("content".to_string(), json!("Hello"))])),
)
.unwrap();
assert_eq!(html, "<html><body><p>Hello</p></body></html>");
cleanup_dir(&dir);
}
#[test]
fn test_view_fallback_with_inheritance() {
let dir = make_temp_dir();
write_template(
&dir,
"base",
"<html>{block name='content'}default{/block}</html>",
);
write_template(
&dir,
"child",
"{extend name='base'}{block name='content'}{$msg}{/block}",
);
let config = ViewConfig {
view_path: dir.clone(),
..Default::default()
};
let fallback = ViewFallback::with_config(config);
let html = fallback
.render_to_string(
"child",
Some(ViewData::from([(
"msg".to_string(),
json!("Hello Inheritance"),
)])),
)
.unwrap();
assert_eq!(html, "<html>Hello Inheritance</html>");
cleanup_dir(&dir);
}
#[tokio::test]
async fn test_view_fallback_complex_template() {
let fallback = ViewFallback::with_default_engine();
let template = r#"<div class="user">
<span>{$name|upper}</span>
<span>{$email|default='N/A'}</span>
<span>{$active?='启用':'禁用'}</span>
</div>"#;
let resp = fallback
.render_display(
template,
Some(ViewData::from([
("name".to_string(), json!("alice")),
("active".to_string(), json!(true)),
])),
)
.unwrap();
let body = extract_body_string(resp).await;
assert!(body.contains("ALICE"));
assert!(body.contains("N/A"));
assert!(body.contains("启用"));
}
}