420 lines
21 KiB
Rust
420 lines
21 KiB
Rust
|
|
// 頁面: pages a bot makes and keeps up to date — a tracker ("台灣 Tesla FSD 追蹤"), a report, a
|
|||
|
|
// comparison — like the original's canvases. The bot writes Markdown (with tables, Mermaid charts
|
|||
|
|
// and a few page blocks: ```stats tiles, ```steps progress, > [!NOTE] call-outs); the chat shows a
|
|||
|
|
// card under the reply, `[標題](page:12)` links open it, and a routine can update it later (each
|
|||
|
|
// update keeps the previous version). Pages live in State, so a brain's pages reach its screens
|
|||
|
|
// and phones with everything else.
|
|||
|
|
|
|||
|
|
use serde::{Deserialize, Serialize};
|
|||
|
|
use serde_json::{json, Value};
|
|||
|
|
|
|||
|
|
/// Older versions kept per page.
|
|||
|
|
pub const KEEP_VERSIONS: usize = 10;
|
|||
|
|
pub const MAX_BODY: usize = 200_000;
|
|||
|
|
const MAX_TITLE: usize = 120;
|
|||
|
|
|
|||
|
|
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)]
|
|||
|
|
pub struct Page {
|
|||
|
|
pub id: u64,
|
|||
|
|
/// The bot that made it (and may change it).
|
|||
|
|
pub bot: u64,
|
|||
|
|
pub title: String,
|
|||
|
|
/// One line under the title on the card ("現況:還在審查第一步").
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub summary: String,
|
|||
|
|
/// Markdown.
|
|||
|
|
pub body: String,
|
|||
|
|
/// ms since 1970.
|
|||
|
|
pub created: i64,
|
|||
|
|
pub updated: i64,
|
|||
|
|
/// 1 for a new page; +1 on every change.
|
|||
|
|
#[serde(default = "one")]
|
|||
|
|
pub revision: u32,
|
|||
|
|
/// What changed last time (the bot's words), shown as 「更新:…」.
|
|||
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|||
|
|
pub note: String,
|
|||
|
|
/// Older versions, oldest first.
|
|||
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|||
|
|
pub history: Vec<Version>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)]
|
|||
|
|
pub struct Version {
|
|||
|
|
pub revision: u32,
|
|||
|
|
pub at: i64,
|
|||
|
|
pub title: String,
|
|||
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|||
|
|
pub summary: String,
|
|||
|
|
pub body: String,
|
|||
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|||
|
|
pub note: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn one() -> u32 {
|
|||
|
|
1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn specs() -> Vec<Value> {
|
|||
|
|
vec. With an id (or the same title) it replaces that page's content and keeps the old version. Write Markdown: headings, short paragraphs, lists, tables, links to sources, Mermaid charts (```mermaid: pie, xychart-beta bar/line, timeline, gantt, flowchart), and these page blocks:\n```stats\n狀態 | 審查中 | 第 1 步\n預計開放 | 2027 Q2 | 媒體推估\n```\n```steps\n[x] 道路測試計畫審查 | 7/9 月開過兩次會\n[>] 道路測試 | 等交通部核定\n[ ] 正式開放\n```\n> [!NOTE] / [!TIP] / [!WARNING] / [!IMPORTANT] call-outs.\nStart with the answer (現況), then the details, then 來源 with links and dates. Keep the whole page current when updating (not a log), and say what changed in `note`.",
|
|||
|
|
"parameters": {"type": "object", "properties": {
|
|||
|
|
"title": {"type": "string", "description": "Short title, e.g. 台灣 Tesla FSD 追蹤"},
|
|||
|
|
"content": {"type": "string", "description": "The whole page in Markdown (without the title)"},
|
|||
|
|
"summary": {"type": "string", "description": "One line for the card: the current state in a few words"},
|
|||
|
|
"note": {"type": "string", "description": "When updating: what changed, in one short sentence"},
|
|||
|
|
"id": {"type": "integer", "description": "The page to update (from list_pages or an earlier write_page)"}},
|
|||
|
|
"required": ["title", "content"]}}),
|
|||
|
|
json!({"name": "read_page", "description": "Read one of your pages (its Markdown), e.g. before updating it.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}, "title": {"type": "string"}}}}),
|
|||
|
|
json!({"name": "list_pages", "description": "List the pages you made (id, title, last update).", "parameters": {"type": "object", "properties": {}}}),
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub const NAMES: [&str; 3] = ["write_page", "read_page", "list_pages"];
|
|||
|
|
|
|||
|
|
pub fn label(name: &str) -> &'static str {
|
|||
|
|
match name {
|
|||
|
|
"write_page" => "寫頁面",
|
|||
|
|
"read_page" => "讀頁面",
|
|||
|
|
_ => "查看頁面",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn describe(name: &str, args: &Value) -> String {
|
|||
|
|
match name {
|
|||
|
|
"write_page" | "read_page" => args["title"].as_str().map(String::from).or_else(|| args["id"].as_u64().map(|i| format!("#{i}"))).unwrap_or_default(),
|
|||
|
|
_ => String::new(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn find<'a>(pages: &'a [Page], bot: u64, id: Option<u64>, title: &str) -> Option<&'a Page> {
|
|||
|
|
match id {
|
|||
|
|
Some(i) => pages.iter().find(|p| p.id == i),
|
|||
|
|
None => pages.iter().find(|p| p.bot == bot && same_title(&p.title, title)),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn same_title(a: &str, b: &str) -> bool {
|
|||
|
|
let n = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
|
|||
|
|
!b.trim().is_empty() && n(a) == n(b)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What write_page did: the page's id and the text for the model.
|
|||
|
|
pub struct Written {
|
|||
|
|
pub id: u64,
|
|||
|
|
pub text: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// write_page: make a page, or replace one (by id, or the same title from this bot).
|
|||
|
|
pub fn write(pages: &mut Vec<Page>, next_id: &mut u64, bot: u64, args: &Value, now: i64) -> Result<Written, String> {
|
|||
|
|
let title: String = args["title"].as_str().unwrap_or("").trim().chars().take(MAX_TITLE).collect();
|
|||
|
|
let body = args["content"].as_str().unwrap_or("").trim().to_string();
|
|||
|
|
let summary: String = args["summary"].as_str().unwrap_or("").trim().chars().take(200).collect();
|
|||
|
|
let note: String = args["note"].as_str().unwrap_or("").trim().chars().take(300).collect();
|
|||
|
|
if title.is_empty() {
|
|||
|
|
return Err("頁面要有標題".into());
|
|||
|
|
}
|
|||
|
|
if body.is_empty() {
|
|||
|
|
return Err("頁面內容是空的".into());
|
|||
|
|
}
|
|||
|
|
if body.len() > MAX_BODY {
|
|||
|
|
return Err(format!("頁面太長了(最多 {} 字)。請精簡,或拆成幾頁。", MAX_BODY));
|
|||
|
|
}
|
|||
|
|
let id = args["id"].as_u64();
|
|||
|
|
let existing = find(pages, bot, id, &title).map(|p| p.id);
|
|||
|
|
if id.is_some() && existing.is_none() {
|
|||
|
|
return Err(format!("找不到頁面 #{}。用 list_pages 看有哪些頁面,或不帶 id 建立新的。", id.unwrap_or(0)));
|
|||
|
|
}
|
|||
|
|
match existing {
|
|||
|
|
Some(pid) => {
|
|||
|
|
let p = pages.iter_mut().find(|p| p.id == pid).unwrap();
|
|||
|
|
if p.bot != bot {
|
|||
|
|
return Err("這是別的 Bot 的頁面,不能改".into());
|
|||
|
|
}
|
|||
|
|
if p.body == body && p.title == title && (summary.is_empty() || p.summary == summary) {
|
|||
|
|
return Ok(Written { id: pid, text: format!("頁面「{}」內容沒有變化,沒有更新。連結:[{}](page:{})", p.title, p.title, pid) });
|
|||
|
|
}
|
|||
|
|
p.history.push(Version { revision: p.revision, at: p.updated, title: p.title.clone(), summary: p.summary.clone(), body: std::mem::take(&mut p.body), note: std::mem::take(&mut p.note) });
|
|||
|
|
if p.history.len() > KEEP_VERSIONS {
|
|||
|
|
let extra = p.history.len() - KEEP_VERSIONS;
|
|||
|
|
p.history.drain(..extra);
|
|||
|
|
}
|
|||
|
|
p.title = title;
|
|||
|
|
p.body = body;
|
|||
|
|
if !summary.is_empty() {
|
|||
|
|
p.summary = summary;
|
|||
|
|
}
|
|||
|
|
p.note = note;
|
|||
|
|
p.revision += 1;
|
|||
|
|
p.updated = now;
|
|||
|
|
Ok(Written { id: pid, text: format!("已更新頁面「{}」(第 {} 版,上一版留著)。在回答裡用 [{}](page:{}) 連到它。", p.title, p.revision, p.title, pid) })
|
|||
|
|
}
|
|||
|
|
None => {
|
|||
|
|
let pid = *next_id;
|
|||
|
|
*next_id += 1;
|
|||
|
|
let text = format!("已建立頁面「{title}」。在回答裡用 [{title}](page:{pid}) 連到它;要更新時用 write_page 帶 id {pid}。");
|
|||
|
|
pages.push(Page { id: pid, bot, title, summary, body, created: now, updated: now, revision: 1, note, history: vec![] });
|
|||
|
|
Ok(Written { id: pid, text })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn read(pages: &[Page], bot: u64, args: &Value) -> Result<String, String> {
|
|||
|
|
let title = args["title"].as_str().unwrap_or("");
|
|||
|
|
let p = find(pages, bot, args["id"].as_u64(), title).ok_or("找不到這個頁面。用 list_pages 看有哪些。")?;
|
|||
|
|
Ok(format!("# {}\n(頁面 #{},第 {} 版,更新於 {}{})\n\n{}", p.title, p.id, p.revision, when(p.updated), if p.summary.is_empty() { String::new() } else { format!(";摘要:{}", p.summary) }, p.body))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn list(pages: &[Page], bot: u64) -> String {
|
|||
|
|
let mine: Vec<&Page> = pages.iter().filter(|p| p.bot == bot).collect();
|
|||
|
|
if mine.is_empty() {
|
|||
|
|
return "你還沒有做任何頁面。".into();
|
|||
|
|
}
|
|||
|
|
mine.iter().map(|p| format!("#{} 「{}」 第 {} 版,更新於 {}{}", p.id, p.title, p.revision, when(p.updated), if p.summary.is_empty() { String::new() } else { format!(" — {}", p.summary) })).collect::<Vec<_>>().join("\n")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// "11/5 09:33" in the local time zone.
|
|||
|
|
pub fn when(ms: i64) -> String {
|
|||
|
|
use chrono::TimeZone;
|
|||
|
|
chrono::Local.timestamp_millis_opt(ms).single().map(|t| t.format("%-m/%-d %H:%M").to_string()).unwrap_or_default()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 「剛剛」「5 分鐘前」「昨天 09:33」「11/3」.
|
|||
|
|
pub fn ago(ms: i64, now: i64) -> String {
|
|||
|
|
use chrono::TimeZone;
|
|||
|
|
let d = (now - ms).max(0) / 1000;
|
|||
|
|
if d < 60 {
|
|||
|
|
return "剛剛".into();
|
|||
|
|
}
|
|||
|
|
if d < 3600 {
|
|||
|
|
return format!("{} 分鐘前", d / 60);
|
|||
|
|
}
|
|||
|
|
let (Some(t), Some(n)) = (chrono::Local.timestamp_millis_opt(ms).single(), chrono::Local.timestamp_millis_opt(now).single()) else { return String::new() };
|
|||
|
|
let days = (n.date_naive() - t.date_naive()).num_days();
|
|||
|
|
match days {
|
|||
|
|
0 => format!("今天 {}", t.format("%H:%M")),
|
|||
|
|
1 => format!("昨天 {}", t.format("%H:%M")),
|
|||
|
|
_ if t.format("%Y").to_string() == n.format("%Y").to_string() => t.format("%-m/%-d").to_string(),
|
|||
|
|
_ => t.format("%Y/%-m/%-d").to_string(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A few lines of plain text for the card: the summary, then the page's first words (no
|
|||
|
|
/// Markdown marks, no code or chart blocks).
|
|||
|
|
pub fn preview(p: &Page, lines: usize) -> Vec<String> {
|
|||
|
|
let mut out = vec![];
|
|||
|
|
if !p.summary.is_empty() {
|
|||
|
|
out.push(p.summary.clone());
|
|||
|
|
}
|
|||
|
|
let mut fence = false;
|
|||
|
|
for l in p.body.lines() {
|
|||
|
|
let t = l.trim();
|
|||
|
|
if t.starts_with("```") {
|
|||
|
|
fence = !fence;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if fence || t.is_empty() || t.starts_with('|') || t.starts_with("---") || t.starts_with('#') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let t = t.trim_start_matches(['-', '*', '>', ' ']).trim();
|
|||
|
|
let t = callout(t).map(|(_, rest)| rest).unwrap_or_else(|| t.to_string());
|
|||
|
|
let t = plain(&t);
|
|||
|
|
if !t.is_empty() && !out.contains(&t) {
|
|||
|
|
out.push(t);
|
|||
|
|
}
|
|||
|
|
if out.len() >= lines {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Markdown marks off: **b**, `c`, [text](url) → text.
|
|||
|
|
pub fn plain(s: &str) -> String {
|
|||
|
|
let mut out = String::new();
|
|||
|
|
let mut rest = s;
|
|||
|
|
while let Some(i) = rest.find('[') {
|
|||
|
|
out.push_str(&rest[..i]);
|
|||
|
|
let after = &rest[i + 1..];
|
|||
|
|
match (after.find("]("), after.find(')')) {
|
|||
|
|
(Some(a), Some(b)) if a < b => {
|
|||
|
|
out.push_str(&after[..a]);
|
|||
|
|
rest = &after[b + 1..];
|
|||
|
|
}
|
|||
|
|
_ => {
|
|||
|
|
out.push('[');
|
|||
|
|
rest = after;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out.push_str(rest);
|
|||
|
|
out.replace("**", "").replace('`', "").replace("__", "")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The id in a `page:12` link.
|
|||
|
|
pub fn link_id(url: &str) -> Option<u64> {
|
|||
|
|
url.strip_prefix("page:").or_else(|| url.strip_prefix("anybot://page/")).and_then(|s| s.trim_matches('/').parse().ok())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// For the phone: the card in a chat, and the whole page.
|
|||
|
|
pub fn card_json(p: &Page, now: i64) -> Value {
|
|||
|
|
json!({"id": p.id, "title": p.title, "preview": preview(p, 4), "revision": p.revision, "updated": ago(p.updated, now), "note": p.note})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn page_json(p: &Page, bot_name: &str, now: i64) -> Value {
|
|||
|
|
json!({"id": p.id, "title": p.title, "summary": p.summary, "body": p.body, "revision": p.revision, "note": p.note,
|
|||
|
|
"updated": when(p.updated), "updated_ago": ago(p.updated, now), "created": when(p.created), "bot": bot_name,
|
|||
|
|
"versions": p.history.iter().rev().map(|v| json!({"revision": v.revision, "at": when(v.at), "note": v.note})).collect::<Vec<_>>()})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// One older version as a whole page (for 「看上一版」).
|
|||
|
|
pub fn version_of(p: &Page, revision: u32) -> Option<Page> {
|
|||
|
|
p.history.iter().find(|v| v.revision == revision).map(|v| Page { id: p.id, bot: p.bot, title: v.title.clone(), summary: v.summary.clone(), body: v.body.clone(), created: p.created, updated: v.at, revision: v.revision, note: v.note.clone(), history: vec![] })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The page's ```mermaid blocks, in order (the phone asks for them one by one as SVG).
|
|||
|
|
pub fn mermaid_blocks(body: &str) -> Vec<String> {
|
|||
|
|
let mut out = vec![];
|
|||
|
|
let mut cur: Option<String> = None;
|
|||
|
|
for l in body.lines() {
|
|||
|
|
let t = l.trim_start();
|
|||
|
|
match &mut cur {
|
|||
|
|
None if t.starts_with("```mermaid") => cur = Some(String::new()),
|
|||
|
|
Some(s) if t.starts_with("```") => {
|
|||
|
|
out.push(std::mem::take(s));
|
|||
|
|
cur = None;
|
|||
|
|
}
|
|||
|
|
Some(s) => {
|
|||
|
|
s.push_str(l);
|
|||
|
|
s.push('\n');
|
|||
|
|
}
|
|||
|
|
None => {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The pages written in one reply (its write_page calls), each once, in order.
|
|||
|
|
pub fn ids_in(calls: &[crate::model::ToolCall]) -> Vec<u64> {
|
|||
|
|
let mut out = vec![];
|
|||
|
|
for c in calls.iter().filter(|c| c.name == "write_page") {
|
|||
|
|
if let Some(id) = serde_json::from_str::<Value>(&c.args).ok().and_then(|v| v["id"].as_u64()) {
|
|||
|
|
if !out.contains(&id) {
|
|||
|
|
out.push(id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// ```stats lines: 標籤 | 數值 | 說明
|
|||
|
|
pub fn stats(src: &str) -> Vec<(String, String, String)> {
|
|||
|
|
src.lines()
|
|||
|
|
.filter_map(|l| {
|
|||
|
|
let c: Vec<&str> = l.split('|').map(str::trim).collect();
|
|||
|
|
(c.len() >= 2 && !c[0].is_empty()).then(|| (plain(c[0]), plain(c[1]), c.get(2).map(|s| plain(s)).unwrap_or_default()))
|
|||
|
|
})
|
|||
|
|
.collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|||
|
|
pub enum Step {
|
|||
|
|
Done,
|
|||
|
|
Now,
|
|||
|
|
Todo,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// ```steps lines: [x] 名稱 | 說明 ([>] = now, [ ] = to do)
|
|||
|
|
pub fn steps(src: &str) -> Vec<(Step, String, String)> {
|
|||
|
|
src.lines()
|
|||
|
|
.filter_map(|l| {
|
|||
|
|
let t = l.trim().trim_start_matches(['-', '*']).trim();
|
|||
|
|
let (st, rest) = if let Some(r) = t.strip_prefix("[x]").or_else(|| t.strip_prefix("[X]")).or_else(|| t.strip_prefix("[✓]")) {
|
|||
|
|
(Step::Done, r)
|
|||
|
|
} else if let Some(r) = t.strip_prefix("[>]").or_else(|| t.strip_prefix("[~]")).or_else(|| t.strip_prefix("[-]")) {
|
|||
|
|
(Step::Now, r)
|
|||
|
|
} else if let Some(r) = t.strip_prefix("[ ]") {
|
|||
|
|
(Step::Todo, r)
|
|||
|
|
} else {
|
|||
|
|
return None;
|
|||
|
|
};
|
|||
|
|
let mut c = rest.splitn(2, '|');
|
|||
|
|
let name = plain(c.next().unwrap_or("").trim());
|
|||
|
|
(!name.is_empty()).then(|| (st, name, plain(c.next().unwrap_or("").trim())))
|
|||
|
|
})
|
|||
|
|
.collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// `> [!WARNING] text` → ("warning", text).
|
|||
|
|
pub fn callout(first_line: &str) -> Option<(&'static str, String)> {
|
|||
|
|
let t = first_line.trim();
|
|||
|
|
let rest = t.strip_prefix("[!")?;
|
|||
|
|
let end = rest.find(']')?;
|
|||
|
|
let kind = match rest[..end].to_ascii_uppercase().as_str() {
|
|||
|
|
"NOTE" | "INFO" => "note",
|
|||
|
|
"TIP" => "tip",
|
|||
|
|
"WARNING" | "CAUTION" => "warning",
|
|||
|
|
"IMPORTANT" => "important",
|
|||
|
|
_ => return None,
|
|||
|
|
};
|
|||
|
|
Some((kind, rest[end + 1..].trim().to_string()))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A sample tracking page (screenshots, and the look the bots are asked for).
|
|||
|
|
pub fn demo(now: i64) -> (Vec<Page>, u64, u64) {
|
|||
|
|
let body = "## 現況\n台灣 FSD **現在還不能用**:交通部把它當 L2 輔助駕駛,走「新技術駕駛系統審查」,不用修法;目前卡在第 1 步,路測計畫還沒核准。\n\n```stats\n目前階段 | 第 1 步 | 道路測試計畫審查\n預計開放 | 2027 Q2 | 媒體推估,官方未公布\n月費 | 未公布 | 網路數字是美國價換算\n你的車 | HW4 | 柏林廠,硬體不卡\n```\n\n## 審查進度\n```steps\n[x] 專家審查會議 | 7 月、9 月各開一次\n[>] 道路測試計畫審查 | 特斯拉依會議結論補件中\n[ ] 道路測試 | 計畫核定後才開始\n[ ] 正式開放\n```\n\n> [!WARNING] 你 12 月交車時多半還不能用,現在先別花錢。等正式開放後先訂閱一個月最划算。\n\n## 時間線\n| 日期 | 消息 | 來源 |\n|---|---|---|\n| 11/4 | 交通部:希望年底前完成相關審查,但不保證年底開放 | NOWnews |\n| 10/2 | 特斯拉:7–9 月完成第一輪技術審查,提出修改後的路測計畫 | 特斯拉 |\n| 6/30 | FSD 買斷停賣 | 特斯拉官網 |\n\n```mermaid\npie title 媒體預估開放時間\n \"2026 年底\" : 1\n \"2027 上半年\" : 3\n \"2027 下半年\" : 4\n```\n\n## 來源\n- [NOWnews 專訪交通部監理司](https://www.nownews.com)(11/4)\n- [車安中心 新技術駕駛系統審查流程](https://www.vscc.org.tw)\n";
|
|||
|
|
let mut pages = vec![];
|
|||
|
|
let mut next = 1;
|
|||
|
|
let first = body.replace("交通部:希望年底前完成相關審查,但不保證年底開放", "(還沒有新消息)");
|
|||
|
|
let _ = write(&mut pages, &mut next, 1, &json!({"title": "台灣 Tesla FSD 追蹤", "content": first, "summary": "還不能用:審查第一步,路測計畫未核准"}), now - 86_400_000);
|
|||
|
|
let id = write(&mut pages, &mut next, 1, &json!({"title": "台灣 Tesla FSD 追蹤", "content": body, "note": "交通部 11/4 的新說法:希望年底前完成審查"}), now - 3_600_000).map(|w| w.id).unwrap_or(1);
|
|||
|
|
(pages, next, id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn write_makes_then_updates_and_keeps_versions() {
|
|||
|
|
let mut pages = vec![];
|
|||
|
|
let mut next = 1;
|
|||
|
|
let w = write(&mut pages, &mut next, 7, &json!({"title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n還在審查", "summary": "審查第一步"}), 1000).unwrap();
|
|||
|
|
assert_eq!(w.id, 1);
|
|||
|
|
assert!(w.text.contains("page:1"));
|
|||
|
|
// Same title, no id: the same page.
|
|||
|
|
let w2 = write(&mut pages, &mut next, 7, &json!({"title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n交通部有新說法", "note": "交通部新說法"}), 2000).unwrap();
|
|||
|
|
assert_eq!(w2.id, 1);
|
|||
|
|
assert_eq!(pages.len(), 1);
|
|||
|
|
assert_eq!(pages[0].revision, 2);
|
|||
|
|
assert_eq!(pages[0].summary, "審查第一步", "kept when not given");
|
|||
|
|
assert_eq!(pages[0].history.len(), 1);
|
|||
|
|
assert_eq!(pages[0].history[0].body, "# 現況\n還在審查");
|
|||
|
|
// Nothing changed: no new version.
|
|||
|
|
let w3 = write(&mut pages, &mut next, 7, &json!({"id": 1, "title": "台灣 Tesla FSD 追蹤", "content": "# 現況\n交通部有新說法"}), 3000).unwrap();
|
|||
|
|
assert!(w3.text.contains("沒有變化"));
|
|||
|
|
assert_eq!(pages[0].revision, 2);
|
|||
|
|
// Another bot can't change it; an unknown id is an error.
|
|||
|
|
assert!(write(&mut pages, &mut next, 8, &json!({"id": 1, "title": "x", "content": "y"}), 4000).is_err());
|
|||
|
|
assert!(write(&mut pages, &mut next, 7, &json!({"id": 99, "title": "x", "content": "y"}), 4000).is_err());
|
|||
|
|
for i in 0..15 {
|
|||
|
|
write(&mut pages, &mut next, 7, &json!({"id": 1, "title": "台灣 Tesla FSD 追蹤", "content": format!("v{i}")}), 5000 + i).unwrap();
|
|||
|
|
}
|
|||
|
|
assert_eq!(pages[0].history.len(), KEEP_VERSIONS);
|
|||
|
|
assert!(version_of(&pages[0], pages[0].revision - 1).is_some());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn preview_and_blocks() {
|
|||
|
|
let p = Page { title: "t".into(), summary: "還不能用".into(), body: "# 現況\n\n```stats\n狀態 | 審查中\n```\n交通部說**年底前**完成審查,見 [來源](https://x.com)。\n\n| a | b |\n|---|---|\n".into(), ..Default::default() };
|
|||
|
|
assert_eq!(preview(&p, 3), vec!["還不能用", "交通部說年底前完成審查,見 來源。"]);
|
|||
|
|
assert_eq!(stats("狀態 | 審查中 | 第 1 步\n預計 | 2027"), vec![("狀態".into(), "審查中".into(), "第 1 步".into()), ("預計".into(), "2027".into(), String::new())]);
|
|||
|
|
let s = steps("[x] 審查 | 開過兩次會\n[>] 路測\n[ ] 開放\n不是步驟");
|
|||
|
|
assert_eq!(s.len(), 3);
|
|||
|
|
assert_eq!((s[0].0, s[1].0, s[2].0), (Step::Done, Step::Now, Step::Todo));
|
|||
|
|
assert_eq!(s[0].2, "開過兩次會");
|
|||
|
|
assert_eq!(callout("[!WARNING] 先別花錢"), Some(("warning", "先別花錢".into())));
|
|||
|
|
assert_eq!(link_id("page:12"), Some(12));
|
|||
|
|
assert_eq!(link_id("https://x"), None);
|
|||
|
|
}
|
|||
|
|
}
|