use std::ops::Range;
use chrono::NaiveDateTime;
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use serde::{Deserialize, Serialize};
use crate::errors::{FlashcardPatchError, ReviewTimeError};
use crate::markdown::{LineIndex, markdown_options, strip_bom};
use crate::tasks::{line_span, split_line_terminator};
pub const HISTORY_LABEL: &str = "___Review History___";
const HISTORY_TEXT: &str = "review history";
const ENTRY_TIME_FORMAT: &str = "%Y-%m-%d %H:%M";
pub const REVIEW_TIME_WINDOW_HOURS: i64 = 26;
pub fn parse_review_time(
at: &str,
now_utc: NaiveDateTime,
) -> Result<NaiveDateTime, ReviewTimeError> {
const SHAPE: &[u8; 16] = b"0000-00-00 00:00";
let shaped = at.len() == SHAPE.len()
&& at.bytes().zip(SHAPE).all(|(byte, &want)| match want {
b'0' => byte.is_ascii_digit(),
separator => byte == separator,
});
if !shaped {
return Err(ReviewTimeError::Malformed);
}
let time = NaiveDateTime::parse_from_str(at, ENTRY_TIME_FORMAT)
.map_err(|_| ReviewTimeError::Malformed)?;
let window = chrono::TimeDelta::hours(REVIEW_TIME_WINDOW_HOURS);
if (time - now_utc).abs() > window {
return Err(ReviewTimeError::OutOfRange);
}
Ok(time)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Rating {
Again,
Hard,
Good,
Easy,
}
impl Rating {
pub fn label(self) -> &'static str {
match self {
Self::Again => "Again",
Self::Hard => "Hard",
Self::Good => "Good",
Self::Easy => "Easy",
}
}
}
pub fn format_entry(reviewed_at: NaiveDateTime, rating: Rating) -> String {
format!(
"{} - {}",
reviewed_at.format(ENTRY_TIME_FORMAT),
rating.label()
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewPatch {
pub source: String,
pub entry: String,
pub inserted_at: u32,
pub inserted: Vec<String>,
}
impl ReviewPatch {
pub fn entry_line(&self) -> u32 {
let index = self
.inserted
.iter()
.rposition(|line| !line.is_empty())
.unwrap_or(0);
self.inserted_at
.saturating_add(u32::try_from(index).unwrap_or(0))
}
}
pub fn append_review(
source: &str,
term_line: u32,
expected: &str,
rating: Rating,
reviewed_at: NaiveDateTime,
) -> Result<ReviewPatch, FlashcardPatchError> {
let span = line_span(source, term_line)
.ok_or(FlashcardPatchError::LineOutOfRange { line: term_line })?;
let (content, term_terminator) = split_line_terminator(&source[span]);
if content != split_line_terminator(expected).0 {
return Err(FlashcardPatchError::Mismatch { line: term_line });
}
let not_a_term = FlashcardPatchError::NotATerm { line: term_line };
let terms = scan_terms(source);
let term = terms
.iter()
.find(|term| term.line == term_line)
.ok_or(not_a_term)?;
let lines = LineIndex::build(source);
let entry = format_entry(reviewed_at, rating);
let (after_line, inserted) = match term.definitions.iter().find(|d| d.history) {
Some(Definition {
last_item: Some(item),
..
}) => (
last_line_of(source, &lines, item),
vec![bullet_after(source, item.start, &entry)],
),
Some(history) => {
let indent = content_indent(&definition_prefix(source, history.range.start));
(
last_line_of(source, &lines, &history.range),
vec![format!("{indent}* {entry}")],
)
}
None => {
let last = term.definitions.last().ok_or(not_a_term)?;
let prefix = definition_prefix(source, last.range.start);
(
last_line_of(source, &lines, &last.range),
vec![
format!("{prefix}{HISTORY_LABEL}"),
format!("{}* {entry}", content_indent(&prefix)),
],
)
}
};
let glued = line_span(source, after_line.saturating_add(1))
.map(|span| split_line_terminator(&source[span]).0)
.is_some_and(could_be_absorbed);
let fallback_terminator = if term_terminator.is_empty() {
"\n"
} else {
term_terminator
};
let build = |separate: bool| {
let inserted: Vec<String> = inserted
.iter()
.cloned()
.chain(separate.then(String::new))
.collect();
ReviewPatch {
source: insert_lines_after(source, after_line, &inserted, fallback_terminator),
entry: entry.clone(),
inserted_at: after_line.saturating_add(1),
inserted,
}
};
let term_lines: Vec<u32> = terms.iter().map(|term| term.line).collect();
std::iter::once(glued)
.chain((!glued).then_some(true))
.map(build)
.find(|patch| keeps_every_card(patch, &term_lines, term_line))
.ok_or(FlashcardPatchError::UnsafeInsert { line: term_line })
}
fn could_be_absorbed(line: &str) -> bool {
let content = line.trim_start_matches([' ', '\t']);
!content.is_empty() && !content.starts_with(':')
}
fn keeps_every_card(patch: &ReviewPatch, term_lines: &[u32], term_line: u32) -> bool {
let shift = u32::try_from(patch.inserted.len()).unwrap_or(u32::MAX);
let after = scan_terms(&patch.source);
let moved = term_lines.iter().map(|&line| {
if line >= patch.inserted_at {
line.saturating_add(shift)
} else {
line
}
});
if !after.iter().map(|term| term.line).eq(moved) {
return false;
}
let Some(span) = line_span(&patch.source, patch.entry_line()) else {
return false;
};
let text = split_line_terminator(&patch.source[span.clone()])
.0
.trim_end();
let Some(entry_end) = (span.start + text.len()).checked_sub(1) else {
return false;
};
after
.iter()
.find(|term| term.line == term_line)
.and_then(|term| term.definitions.iter().find(|d| d.history))
.and_then(|history| history.last_item.as_ref())
.is_some_and(|item| item.contains(&entry_end))
}
pub fn is_history_label(text: &str) -> bool {
text.trim().eq_ignore_ascii_case(HISTORY_TEXT)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Term {
line: u32,
definitions: Vec<Definition>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Definition {
range: Range<usize>,
history: bool,
last_item: Option<Range<usize>>,
}
#[derive(Debug)]
enum Label {
Pending,
Capturing {
depth: usize,
text: String,
},
Decided(bool),
}
const CONTENT_DEPTH: usize = 2;
#[derive(Debug)]
struct DefinitionScan {
range: Range<usize>,
label: Label,
list_depth: Option<usize>,
list_closed: bool,
last_item: Option<Range<usize>>,
}
impl DefinitionScan {
fn new(range: Range<usize>) -> Self {
Self {
range,
label: Label::Pending,
list_depth: None,
list_closed: false,
last_item: None,
}
}
fn start(&mut self, tag: &Tag<'_>, depth: usize, range: &Range<usize>) {
if matches!(self.label, Label::Pending) {
self.label = match tag {
Tag::Paragraph if depth == CONTENT_DEPTH => Label::Pending,
Tag::Emphasis | Tag::Strong => Label::Capturing {
depth,
text: String::new(),
},
_ => Label::Decided(false),
};
}
match tag {
Tag::List(_) if depth == CONTENT_DEPTH && self.list_depth.is_none() => {
self.list_depth = Some(depth);
}
Tag::Item if !self.list_closed && self.list_depth.is_some_and(|d| d + 1 == depth) => {
self.last_item = Some(range.clone());
}
_ => {}
}
}
fn end(&mut self, tag: &TagEnd, depth: usize) {
if let Label::Capturing {
depth: opened,
text,
} = &self.label
&& *opened == depth
{
self.label = Label::Decided(is_history_label(text));
}
if matches!(tag, TagEnd::List(_)) && self.list_depth == Some(depth) {
self.list_closed = true;
}
}
fn inline(&mut self, event: &Event<'_>) {
match &mut self.label {
Label::Capturing { text, .. } => {
if let Event::Text(t) | Event::Code(t) = event {
text.push_str(t);
}
}
Label::Pending => self.label = Label::Decided(false),
Label::Decided(_) => {}
}
}
fn finish(self) -> Definition {
Definition {
range: self.range,
history: matches!(self.label, Label::Decided(true)),
last_item: self.last_item,
}
}
}
fn scan_terms(source: &str) -> Vec<Term> {
let body = strip_bom(source);
let shift = source.len() - body.len();
let lines = LineIndex::build(source);
let mut terms = Vec::new();
let mut depth = 0usize;
let mut in_top_list = false;
let mut term: Option<Term> = None;
let mut definition: Option<DefinitionScan> = None;
for (event, range) in Parser::new_ext(body, markdown_options()).into_offset_iter() {
let range = range.start + shift..range.end + shift;
match event {
Event::Start(tag) => {
match (&tag, depth) {
(Tag::DefinitionList, 0) => in_top_list = true,
(Tag::DefinitionListTitle, 1) if in_top_list => {
let next = Term {
line: lines.line_of(range.start),
definitions: Vec::new(),
};
terms.extend(term.replace(next));
}
(Tag::DefinitionListDefinition, 1) if in_top_list => {
definition = Some(DefinitionScan::new(range));
}
_ => {
if let Some(scan) = definition.as_mut() {
scan.start(&tag, depth, &range);
}
}
}
depth += 1;
}
Event::End(tag) => {
depth = depth.saturating_sub(1);
match (&tag, depth) {
(TagEnd::DefinitionListDefinition, 1) if in_top_list => {
if let (Some(scan), Some(open)) = (definition.take(), term.as_mut()) {
open.definitions.push(scan.finish());
}
}
(TagEnd::DefinitionList, 0) if in_top_list => {
in_top_list = false;
terms.extend(term.take());
}
_ => {
if let Some(scan) = definition.as_mut() {
scan.end(&tag, depth);
}
}
}
}
other => {
if let Some(scan) = definition.as_mut() {
scan.inline(&other);
}
}
}
}
terms.extend(term);
terms
}
fn line_start(source: &str, offset: usize) -> usize {
source[..offset]
.rfind('\n')
.map_or(0, |newline| newline + 1)
}
fn line_containing(source: &str, offset: usize) -> &str {
let start = line_start(source, offset);
let end = source[start..]
.find('\n')
.map_or(source.len(), |newline| start + newline);
source[start..end].trim_end_matches('\r')
}
fn last_line_of(source: &str, lines: &LineIndex, range: &Range<usize>) -> u32 {
let text = &source[range.clone()];
let content = text.trim_end();
if content.is_empty() {
lines.line_of(range.start)
} else {
lines.line_of(range.start + content.len() - 1)
}
}
fn definition_prefix(source: &str, offset: usize) -> String {
const MAX_GAP: usize = 4;
let line = line_containing(source, offset);
let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
let gap = line[lead..]
.strip_prefix(':')
.map_or(0, |rest| rest.len() - rest.trim_start_matches(' ').len());
let gap = if (1..=MAX_GAP).contains(&gap) { gap } else { 1 };
let lead = if line[lead..].starts_with(':') {
&line[..lead]
} else {
""
};
format!("{lead}:{}", " ".repeat(gap))
}
fn content_indent(prefix: &str) -> String {
prefix.replace(':', " ")
}
fn bullet_after(source: &str, item_start: usize, entry: &str) -> String {
let start = line_start(source, item_start);
let indent: String = source[start..item_start]
.chars()
.map(|c| if c == '\t' { '\t' } else { ' ' })
.collect();
let rest = line_containing(source, item_start);
let rest = &rest[item_start - start..];
let marker_len = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let marker = &rest[..marker_len];
let after = &rest[marker_len..];
let gap = &after[..after.len() - after.trim_start_matches([' ', '\t']).len()];
let (marker, gap) = if marker.is_empty() {
("*", " ")
} else if gap.is_empty() {
(marker, " ")
} else {
(marker, gap)
};
format!("{indent}{marker}{gap}{entry}")
}
fn insert_lines_after(
source: &str,
after_line: u32,
inserted: &[String],
fallback: &str,
) -> String {
let added: usize = inserted.iter().map(|line| line.len() + 2).sum();
let mut out = String::with_capacity(source.len() + added);
let Some(span) = line_span(source, after_line) else {
out.push_str(source);
for line in inserted {
out.push_str(fallback);
out.push_str(line);
}
return out;
};
let (_, terminator) = split_line_terminator(&source[span.clone()]);
if terminator.is_empty() {
out.push_str(source);
for line in inserted {
out.push_str(fallback);
out.push_str(line);
}
} else {
out.push_str(&source[..span.end]);
for line in inserted {
out.push_str(line);
out.push_str(terminator);
}
out.push_str(&source[span.end..]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use proptest::prelude::*;
fn at(h: u32, m: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(2026, 10, 6)
.and_then(|d| d.and_hms_opt(h, m, 0))
.expect("valid test time")
}
fn append(source: &str, line: u32) -> Result<ReviewPatch, FlashcardPatchError> {
let expected = source
.split('\n')
.nth(line as usize - 1)
.unwrap_or_default()
.trim_end_matches('\r');
append_review(source, line, expected, Rating::Good, at(13, 45))
}
fn appended(source: &str, line: u32) -> String {
append(source, line).expect("append succeeds").source
}
#[test]
fn entry_format_is_minutes_and_capitalised_rating() {
assert_eq!(
format_entry(at(9, 5), Rating::Again),
"2026-10-06 09:05 - Again"
);
assert_eq!(
format_entry(at(23, 59), Rating::Easy),
"2026-10-06 23:59 - Easy"
);
}
fn utc_now() -> NaiveDateTime {
at(12, 0)
}
#[test]
fn review_time_accepts_any_real_time_zone() {
for at in ["2026-10-06 00:00", "2026-10-06 12:00", "2026-10-07 02:00"] {
assert_eq!(
parse_review_time(at, utc_now()).map(|t| format_entry(t, Rating::Good)),
Ok(format!("{at} - Good")),
"{at}"
);
}
assert!(parse_review_time("2026-10-05 10:00", utc_now()).is_ok());
assert!(parse_review_time("2026-10-07 14:00", utc_now()).is_ok());
}
#[test]
fn review_time_refuses_times_no_time_zone_can_explain() {
for at in [
"2026-10-05 09:59", "2026-10-07 14:01", "1970-01-01 00:00",
"9999-12-31 23:59",
] {
assert_eq!(
parse_review_time(at, utc_now()),
Err(ReviewTimeError::OutOfRange),
"{at}"
);
}
}
#[test]
fn review_time_is_strictly_the_entry_format() {
for at in [
"",
"now",
"2026-10-06",
"2026-10-06 12:00:00", "2026-10-06T12:00",
"2026-10-06 12:00Z",
"2026-10-06 12:00 ",
" 2026-10-06 12:00",
"2026-10-6 12:00",
"2026-10-06 2:00",
"+2026-10-06 12:00",
"2026-10-06 12:00", "2026-10-06 12:00 - Good",
] {
assert_eq!(
parse_review_time(at, utc_now()),
Err(ReviewTimeError::Malformed),
"{at:?}"
);
}
}
#[test]
fn review_time_refuses_impossible_calendar_values() {
for at in [
"2026-13-01 12:00",
"2026-00-10 12:00",
"2026-02-29 12:00", "2026-10-32 12:00",
"2026-10-06 24:00",
"2026-10-06 12:60",
"2016-12-31 23:60",
"2016-12-31 23:59:60",
] {
assert_eq!(
parse_review_time(at, utc_now()),
Err(ReviewTimeError::Malformed),
"{at}"
);
}
}
proptest! {
#[test]
fn review_time_never_panics_and_round_trips(at in "\\PC{0,24}") {
if let Ok(time) = parse_review_time(&at, utc_now()) {
prop_assert_eq!(time.format(ENTRY_TIME_FORMAT).to_string(), at);
}
}
}
#[test]
fn rating_deserialises_lowercase_only() {
let good: Rating = serde_json::from_str("\"good\"").unwrap();
assert_eq!(good, Rating::Good);
assert!(serde_json::from_str::<Rating>("\"Good\"").is_err());
assert!(serde_json::from_str::<Rating>("\"fail\"").is_err());
}
#[test]
fn history_label_matching() {
assert!(is_history_label("Review History"));
assert!(is_history_label(" review history "));
assert!(is_history_label("REVIEW HISTORY"));
assert!(!is_history_label("Review"));
assert!(!is_history_label("Review History of Rome"));
}
#[test]
fn first_review_creates_the_history_definition() {
let source = "Q?\n: A.\n\nNext?\n: B.\n";
let patch = append(source, 1).unwrap();
assert_eq!(
patch.source,
"Q?\n: A.\n: ___Review History___\n * 2026-10-06 13:45 - Good\n\nNext?\n: B.\n"
);
assert_eq!(patch.entry, "2026-10-06 13:45 - Good");
assert_eq!(patch.inserted_at, 3);
assert_eq!(
patch.inserted,
vec![": ___Review History___", " * 2026-10-06 13:45 - Good"]
);
assert_eq!(patch.entry_line(), 4);
}
#[test]
fn history_goes_after_the_last_of_several_answers() {
let source = "Q?\n: one\n: two\n";
assert_eq!(
appended(source, 1),
"Q?\n: one\n: two\n: ___Review History___\n * 2026-10-06 13:45 - Good\n"
);
}
#[test]
fn history_copies_a_wider_definition_prefix() {
let source = "Q?\n: A wide answer\n";
assert_eq!(
appended(source, 1),
"Q?\n: A wide answer\n: ___Review History___\n * 2026-10-06 13:45 - Good\n"
);
}
#[test]
fn loose_definitions_get_the_history_after_the_last_content_line() {
let source = "Q?\n\n: A.\n\n: B.\n\nNext?\n\n: C.\n";
assert_eq!(
appended(source, 1),
"Q?\n\n: A.\n\n: B.\n: ___Review History___\n * 2026-10-06 13:45 - Good\n\nNext?\n\n: C.\n"
);
}
#[test]
fn multi_line_answer_with_nested_list_and_code() {
let source = concat!(
"Q?\n",
": Steps:\n",
" * first\n",
" * second\n",
"\n",
" ```\n",
" : not a definition\n",
" ```\n",
"\n",
"Next?\n",
": B.\n",
);
let patched = appended(source, 1);
assert_eq!(
patched,
concat!(
"Q?\n",
": Steps:\n",
" * first\n",
" * second\n",
"\n",
" ```\n",
" : not a definition\n",
" ```\n",
": ___Review History___\n",
" * 2026-10-06 13:45 - Good\n",
"\n",
"Next?\n",
": B.\n",
)
);
let terms = scan_terms(&patched);
assert_eq!(terms[0].definitions.len(), 2);
assert!(!terms[0].definitions[0].history);
assert!(terms[0].definitions[1].history);
}
#[test]
fn appends_after_the_last_history_bullet() {
let source = concat!(
"Q?\n",
": A.\n",
": ___Review History___\n",
" * 2026-10-01 08:00 - Again\n",
" * 2026-10-02 09:00 - Good\n",
"\n",
"Next?\n",
": B.\n",
);
let patch = append(source, 1).unwrap();
assert_eq!(patch.inserted, vec![" * 2026-10-06 13:45 - Good"]);
assert_eq!(patch.inserted_at, 6);
assert_eq!(patch.entry_line(), 6);
assert_eq!(
patch.source,
concat!(
"Q?\n",
": A.\n",
": ___Review History___\n",
" * 2026-10-01 08:00 - Again\n",
" * 2026-10-02 09:00 - Good\n",
" * 2026-10-06 13:45 - Good\n",
"\n",
"Next?\n",
": B.\n",
)
);
}
#[test]
fn keeps_dash_markers_and_wide_gaps() {
let source = "Q?\n: A.\n: **Review History**\n - 2026-10-01 08:00 - Hard\n";
assert_eq!(
appended(source, 1),
"Q?\n: A.\n: **Review History**\n - 2026-10-01 08:00 - Hard\n - 2026-10-06 13:45 - Good\n"
);
}
#[test]
fn recognises_single_emphasis_and_any_case() {
let source = "Q?\n: A.\n: *review history*\n * 2026-10-01 08:00 - Fail\n";
let patch = append(source, 1).unwrap();
assert_eq!(patch.inserted, vec![" * 2026-10-06 13:45 - Good"]);
}
#[test]
fn plain_text_review_history_is_an_answer_not_a_history() {
let source = "Q?\n: Review History\n";
let patch = append(source, 1).unwrap();
assert_eq!(patch.inserted.len(), 2, "a real history is created");
}
#[test]
fn label_only_history_gets_its_first_bullet() {
let source = "Q?\n: A.\n: ___Review History___\n\nNext?\n: B.\n";
assert_eq!(
appended(source, 1),
"Q?\n: A.\n: ___Review History___\n * 2026-10-06 13:45 - Good\n\nNext?\n: B.\n"
);
}
#[test]
fn loose_history_list_appends_after_last_item() {
let source = concat!(
"Q?\n\n",
": A.\n\n",
": ___Review History___\n\n",
" * 2026-10-01 08:00 - Good\n\n",
" * 2026-10-02 08:00 - Easy\n\n",
"Next?\n\n",
": B.\n",
);
let patched = appended(source, 1);
assert!(
patched.contains(" * 2026-10-02 08:00 - Easy\n * 2026-10-06 13:45 - Good\n\nNext?"),
"{patched}"
);
}
#[test]
fn unparseable_history_lines_are_left_alone() {
let source = "Q?\n: A.\n: ___Review History___\n * yesterday, sort of\n";
assert_eq!(
appended(source, 1),
"Q?\n: A.\n: ___Review History___\n * yesterday, sort of\n * 2026-10-06 13:45 - Good\n"
);
}
#[test]
fn second_term_in_the_same_list_and_terms_in_other_lists() {
let source = concat!(
"# Deck\n", "\n", "One?\n", ": 1\n", "\n", "Two?\n", ": 2\n", "\n", "A paragraph.\n", "\n", "Three?\n", ": 3\n", );
assert!(appended(source, 6).contains("Two?\n: 2\n: ___Review History___\n * "));
assert!(
appended(source, 11)
.ends_with("Three?\n: 3\n: ___Review History___\n * 2026-10-06 13:45 - Good\n")
);
let one = appended(source, 3);
assert!(
one.starts_with("# Deck\n\nOne?\n: 1\n: ___Review History___\n"),
"{one}"
);
}
#[test]
fn frontmatter_counts_toward_line_numbers() {
let source = "---\ntype: flashcard\n---\n\nQ?\n: A.\n";
assert!(
appended(source, 5)
.ends_with(": A.\n: ___Review History___\n * 2026-10-06 13:45 - Good\n")
);
assert_eq!(
append(source, 2),
Err(FlashcardPatchError::NotATerm { line: 2 })
);
}
#[test]
fn bom_does_not_shift_anything() {
let source = "\u{feff}---\ntype: flashcard\n---\nQ?\n: A.\n";
let patched = appended(source, 4);
assert!(patched.starts_with('\u{feff}'));
assert!(patched.ends_with(": A.\n: ___Review History___\n * 2026-10-06 13:45 - Good\n"));
}
#[test]
fn colon_lines_in_code_fences_are_not_terms() {
let source = "```\nFake?\n: not a definition\n```\n\nReal?\n: yes\n";
assert_eq!(
append(source, 2),
Err(FlashcardPatchError::NotATerm { line: 2 })
);
assert!(
appended(source, 6)
.ends_with(": yes\n: ___Review History___\n * 2026-10-06 13:45 - Good\n")
);
}
#[test]
fn terms_inside_chat_blocks_are_not_terms() {
let source = "```chat\n{{Alice|Chat term\n: chat answer\n|}}\n```\n\nReal?\n: yes\n";
assert_eq!(
append(source, 2),
Err(FlashcardPatchError::NotATerm { line: 2 })
);
assert!(
appended(source, 7)
.ends_with(": yes\n: ___Review History___\n * 2026-10-06 13:45 - Good\n")
);
}
#[test]
fn definition_lines_and_prose_are_not_terms() {
let source = "Prose.\n\nQ?\n: A.\n";
assert_eq!(
append(source, 1),
Err(FlashcardPatchError::NotATerm { line: 1 })
);
assert_eq!(
append(source, 4),
Err(FlashcardPatchError::NotATerm { line: 4 })
);
}
#[test]
fn nested_definition_lists_are_refused() {
let source = "> Q?\n> : A.\n\n- Q2?\n : A2.\n";
assert_eq!(
append(source, 1),
Err(FlashcardPatchError::NotATerm { line: 1 })
);
assert_eq!(
append(source, 4),
Err(FlashcardPatchError::NotATerm { line: 4 })
);
}
#[test]
fn mismatch_and_out_of_range() {
let source = "Q?\n: A.\n";
assert_eq!(
append_review(source, 1, "Q? (edited)", Rating::Good, at(1, 2)),
Err(FlashcardPatchError::Mismatch { line: 1 })
);
assert_eq!(
append_review(source, 9, "Q?", Rating::Good, at(1, 2)),
Err(FlashcardPatchError::LineOutOfRange { line: 9 })
);
assert_eq!(
append_review(source, 0, "Q?", Rating::Good, at(1, 2)),
Err(FlashcardPatchError::LineOutOfRange { line: 0 })
);
}
#[test]
fn expected_is_compared_modulo_terminator() {
let source = "Q?\r\n: A.\r\n";
assert!(append_review(source, 1, "Q?\r\n", Rating::Hard, at(1, 2)).is_ok());
assert!(append_review(source, 1, "Q?", Rating::Hard, at(1, 2)).is_ok());
}
#[test]
fn crlf_is_preserved() {
let source = "Q?\r\n: A.\r\n: ___Review History___\r\n * 2026-10-01 08:00 - Good\r\n";
assert_eq!(
appended(source, 1),
"Q?\r\n: A.\r\n: ___Review History___\r\n * 2026-10-01 08:00 - Good\r\n * 2026-10-06 13:45 - Good\r\n"
);
let fresh = "Q?\r\n: A.\r\n";
assert_eq!(
appended(fresh, 1),
"Q?\r\n: A.\r\n: ___Review History___\r\n * 2026-10-06 13:45 - Good\r\n"
);
}
#[test]
fn missing_trailing_newline_stays_missing() {
assert_eq!(
appended("Q?\n: A.", 1),
"Q?\n: A.\n: ___Review History___\n * 2026-10-06 13:45 - Good"
);
assert_eq!(
appended("Q?\r\n: A.", 1),
"Q?\r\n: A.\r\n: ___Review History___\r\n * 2026-10-06 13:45 - Good"
);
assert_eq!(
appended(
"Q?\n: A.\n: ___Review History___\n * 2026-10-01 08:00 - Good",
1
),
"Q?\n: A.\n: ___Review History___\n * 2026-10-01 08:00 - Good\n * 2026-10-06 13:45 - Good"
);
}
#[test]
fn repeated_reviews_accumulate() {
let mut source = "Q?\n: A.\n\nNext?\n: B.\n".to_string();
for (i, rating) in [Rating::Again, Rating::Hard, Rating::Good, Rating::Easy]
.into_iter()
.enumerate()
{
source = append_review(&source, 1, "Q?", rating, at(10, i as u32))
.unwrap()
.source;
}
assert_eq!(
source,
concat!(
"Q?\n",
": A.\n",
": ___Review History___\n",
" * 2026-10-06 10:00 - Again\n",
" * 2026-10-06 10:01 - Hard\n",
" * 2026-10-06 10:02 - Good\n",
" * 2026-10-06 10:03 - Easy\n",
"\n",
"Next?\n",
": B.\n",
)
);
}
fn rendered_terms(source: &str) -> Vec<String> {
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, Parser::new_ext(source, markdown_options()));
html.split("<dt>")
.skip(1)
.map(|rest| rest[..rest.find("</dt>").expect("closed dt")].to_string())
.collect()
}
fn assert_rating_keeps_cards(source: &str, line: u32) -> String {
let terms = rendered_terms(source);
let before = outline(source);
let patched = appended(source, line);
assert_eq!(rendered_terms(&patched), terms, "{patched}");
let after = outline(&patched);
assert_eq!(after.len(), before.len(), "{patched}");
for (i, (b, a)) in before.iter().zip(&after).enumerate() {
assert_eq!((&b.0, &b.1), (&a.0, &a.1), "card {i}: {patched}");
let target = b.0 == line_containing(source, line_span(source, line).unwrap().start);
let grown = if target {
b.2.unwrap_or(0) + 1
} else {
b.2.unwrap_or(0)
};
assert_eq!(a.2.unwrap_or(0), grown, "card {i}: {patched}");
}
patched
}
#[test]
fn a_term_glued_to_an_answer_is_a_lazy_continuation() {
let source = "Q1\n: A1\nQ2\n: A2\n";
assert_eq!(rendered_terms(source), ["Q1"]);
assert_eq!(
append(source, 3),
Err(FlashcardPatchError::NotATerm { line: 3 })
);
let patched = assert_rating_keeps_cards(source, 1);
assert_eq!(
patched,
"Q1\n: A1\nQ2\n: A2\n: ___Review History___\n * 2026-10-06 13:45 - Good\n"
);
}
#[test]
fn a_term_glued_to_a_history_bullet_is_a_lazy_continuation() {
let source = "Q1\n: A1\n: ___Review History___\n * 2026-10-01 08:00 - Good\nQ2\n: A2\n";
assert_eq!(rendered_terms(source), ["Q1"]);
assert_rating_keeps_cards(source, 1);
}
#[test]
fn a_term_glued_to_a_loose_answer_is_a_lazy_continuation() {
let source = "Q1\n\n: A1\nQ2\n\n: A2\n";
assert_eq!(rendered_terms(source), ["Q1"]);
assert_rating_keeps_cards(source, 1);
}
#[test]
fn a_term_glued_to_a_closed_fence_stays_a_term() {
let source = "Q1\n: A1\n\n ```\n code\n ```\nQ2\n: A2\n";
assert_eq!(rendered_terms(source), ["Q1", "Q2"]);
let patched = assert_rating_keeps_cards(source, 1);
assert_eq!(
patched,
concat!(
"Q1\n: A1\n\n ```\n code\n ```\n",
": ___Review History___\n",
" * 2026-10-06 13:45 - Good\n",
"\n",
"Q2\n: A2\n",
)
);
let patch = append(source, 1).unwrap();
assert_eq!(patch.inserted.last().map(String::as_str), Some(""));
assert_eq!((patch.inserted_at, patch.entry_line()), (7, 8));
let q2_card = |source: &str| {
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, Parser::new_ext(source, markdown_options()));
html[html.find("<dt>Q2</dt>").expect("Q2 is a term")..].to_string()
};
assert_eq!(q2_card(&patched), q2_card(source));
let again = assert_rating_keeps_cards(&patched, 1);
assert!(
again.contains("13:45 - Good\n * 2026-10-06 13:45 - Good\n\nQ2\n"),
"{again}"
);
}
#[test]
fn an_indented_term_glued_to_a_closed_fence_stays_a_term() {
let source = "Q1\n: A1\n\n ```\n code\n ```\n Q2\n: A2\n";
assert_eq!(rendered_terms(source), ["Q1", "Q2"]);
let patched = assert_rating_keeps_cards(source, 1);
assert_eq!(
patched,
concat!(
"Q1\n: A1\n\n ```\n code\n ```\n",
": ___Review History___\n",
" * 2026-10-06 13:45 - Good\n",
"\n",
" Q2\n: A2\n",
)
);
assert_rating_keeps_cards(source, 7);
for indent in [" ", " "] {
let inside = source.replace("\n Q2", &format!("\n{indent}Q2"));
assert_eq!(rendered_terms(&inside), ["Q1"], "{inside:?}");
assert_rating_keeps_cards(&inside, 1);
}
}
#[test]
fn a_term_glued_to_a_history_bullet_ending_in_a_fence_stays_a_term() {
for indent in ["", " "] {
let source = format!(
concat!(
"Q1\n",
": A1\n",
": ___Review History___\n",
" * 2026-10-01 08:00 - Good\n",
"\n",
" ```\n",
" x\n",
" ```\n",
"{}Q2\n",
": A2\n",
),
indent
);
assert_eq!(rendered_terms(&source), ["Q1", "Q2"], "{source:?}");
let patch = append(&source, 1).unwrap();
assert_eq!(
patch.inserted,
vec![" * 2026-10-06 13:45 - Good", ""],
"{source:?}"
);
assert_eq!((patch.inserted_at, patch.entry_line()), (9, 9));
let again = assert_rating_keeps_cards(&assert_rating_keeps_cards(&source, 1), 1);
assert_eq!(rendered_terms(&again), ["Q1", "Q2"]);
}
}
#[test]
fn verification_rejects_a_patch_that_swallows_a_card() {
let source = "Q1\n: A1\n\n ```\n code\n ```\n Q2\n: A2\n";
let inserted = vec![
": ___Review History___".to_string(),
" * 2026-10-06 13:45 - Good".to_string(),
];
let bad = ReviewPatch {
source: insert_lines_after(source, 6, &inserted, "\n"),
entry: "2026-10-06 13:45 - Good".to_string(),
inserted_at: 7,
inserted,
};
assert_eq!(rendered_terms(&bad.source), ["Q1"], "the patch is bad");
let term_lines: Vec<u32> = scan_terms(source).iter().map(|t| t.line).collect();
assert_eq!(term_lines, [1, 7]);
assert!(!keeps_every_card(&bad, &term_lines, 1));
assert!(keeps_every_card(
&append(source, 1).unwrap(),
&term_lines,
1
));
}
#[test]
fn a_wide_or_tabbed_answer_gap_is_not_copied_onto_the_history() {
for source in [
"Q?\n: A\n",
"Q?\n:\t\tA\n",
"Q?\n: \tA\n",
"Q?\n: A\n",
] {
let mut patched = source.to_string();
for minute in 0..3 {
patched = append_review(&patched, 1, "Q?", Rating::Good, at(10, minute))
.unwrap()
.source;
}
assert_eq!(
patched,
format!(
"{source}: ___Review History___\n * 2026-10-06 10:00 - Good\n * 2026-10-06 10:01 - Good\n * 2026-10-06 10:02 - Good\n"
),
"{source:?}"
);
let terms = scan_terms(&patched);
assert_eq!(terms.len(), 1);
assert_eq!(
terms[0].definitions.iter().filter(|d| d.history).count(),
1,
"{patched:?}"
);
}
assert_eq!(
appended("Q?\n: A\n", 1),
"Q?\n: A\n: ___Review History___\n * 2026-10-06 13:45 - Good\n"
);
assert_eq!(
appended("Q?\n : A\n", 1),
"Q?\n : A\n : ___Review History___\n * 2026-10-06 13:45 - Good\n"
);
}
fn ham_card(n: u32, history: &str) -> String {
format!(
concat!(
"**T1-A{n:02}: Who selects a Frequency Coordinator?**\n",
"\n",
"A. The FCC Office of Spectrum Management and Coordination Policy\n",
"B. The local chapter of the Office of National Council of Independent Frequency Coordinators\n",
"C. Amateur operators in a local or regional area whose stations are eligible to be repeater or auxiliary stations\n",
"D. The FCC Regional Field Office\n",
": **C.** Amateur operators in a local or regional area whose stations are eligible to be repeater or auxiliary stations\n",
": **47 CFR 97.3(a)(22):**\n",
": > **Frequency coordinator.** An entity, ***recognized in a local or regional area by amateur operators whose stations are eligible to be auxiliary or repeater stations,*** that recommends transmit/receive channels and associated operating and technical parameters for such stations in order to avoid or minimize potential interference.\n",
": Frequency coordinators are amateur radio operators who are recognized by other local or regional amateurs (particularly those who operate repeaters or auxiliary stations). They recommend which transmit/receive channels and what operating and technical parameters should be used so repeaters don't interfere with one another. This local recognition means the coordinators are chosen by the amateur community in the area rather than by an FCC office.\n",
": Coordination is important because if an operator ignores the locally established band plan or the coordinator's recommendations, they might transmit on or near a repeater input and cause harmful interference to others.\n",
": Memory aids:\n",
" * Remember: \"Frequency coordinator\" = someone recognized by local/regional amateur operators (not an FCC office).\n",
"{history}",
),
n = n,
history = history,
)
}
const HAM_TERM: &str = "A. The FCC Office of Spectrum Management and Coordination Policy";
const HAM_TERM_OFFSET: u32 = 2;
const HAM_REMEMBER: &str = " * Remember: \"Frequency coordinator\"";
const HAM_HISTORY: &str = ": ___Review History___\n * 2026-10-07 14:53 - Good\n";
fn render_page(source: &str) -> String {
use crate::link_transform::LinkTransformConfig;
use crate::markdown::{RenderOptions, ReviewLines, render_str};
let root = std::path::Path::new("/nonexistent-root");
let opts = RenderOptions {
root_path: root,
oembed_timeout_ms: 0,
link_transform_config: LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
},
oembed_cache: None,
server_mode: true,
transcode_enabled: false,
valid_tag_sources: Default::default(),
review: ReviewLines::Emit,
mark_incomplete: false,
incomplete_markers: &[],
wikilink_index: None,
};
render_str(source, std::path::Path::new("deck.md"), opts)
.expect("renders")
.html
}
fn page_term_lines(html: &str) -> Vec<u32> {
html.match_indices("<dt data-mbr-line=\"")
.map(|(at, prefix)| {
let rest = &html[at + prefix.len()..];
rest[..rest.find('"').expect("closing quote")]
.parse()
.expect("numeric line")
})
.collect()
}
fn assert_bullets_inside_definitions(html: &str, entries: &[&str]) -> usize {
let mut outside = html.to_string();
let mut lists = 0;
while let Some(open) = outside.find("<dl>") {
let close = open + outside[open..].find("</dl>").expect("closed dl") + "</dl>".len();
let list = outside[open..close].to_string();
assert!(list.contains("<li"), "a <dl> without its bullets: {html}");
outside.replace_range(open..close, "");
lists += 1;
}
assert!(!outside.contains("<li"), "a bullet left the <dl>: {html}");
assert!(!outside.contains("<ul"), "a list left the <dl>: {html}");
for entry in entries {
assert!(html.contains(entry), "{entry:?} missing: {html}");
}
let answers = html.matches("Memory aids:").count();
assert!(answers > 0, "{html}");
assert_eq!(
html.matches("Memory aids:\n<ul>\n<li").count(),
answers,
"{html}"
);
lists
}
fn assert_pure_insert(before: &str, patch: &ReviewPatch) {
let terminator = if before.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let mut lines: Vec<&str> = before.split(terminator).collect();
let at = patch.inserted_at as usize - 1;
let unterminated_tail = !before.ends_with('\n') && at == lines.len();
for (i, line) in patch.inserted.iter().enumerate() {
lines.insert(at + i, line);
}
let rebuilt = lines.join(terminator);
assert_eq!(patch.source, rebuilt, "inserted {:?}", patch.inserted);
if unterminated_tail {
assert!(!patch.source.ends_with('\n'));
}
}
fn ham_append(source: &str, line: u32, rating: Rating, minute: u32) -> ReviewPatch {
let patch = append_review(source, line, HAM_TERM, rating, at(13, minute))
.unwrap_or_else(|e| panic!("append at {line}: {e:?}\n{source}"));
assert_pure_insert(source, &patch);
patch
}
#[test]
fn ham_deck_reported_corruption_moves_bullets_out_of_the_list() {
let good = ham_card(9, HAM_HISTORY);
let broken = good
.replace("\n * Remember", "\n\n* Remember")
.replace("\n * 2026", "\n\n* 2026");
let html = render_page(&broken);
let remember = html.find("Remember: ").expect("rendered");
let dl_end = html.find("</dl>").expect("a <dl>");
assert!(dl_end < remember, "{html}");
assert!(
std::panic::catch_unwind(|| assert_bullets_inside_definitions(&html, &[])).is_err()
);
assert!(
std::panic::catch_unwind(|| {
assert_bullets_inside_definitions(&render_page(&good), &[])
})
.is_ok()
);
}
#[test]
fn ham_deck_term_is_the_choices_paragraph() {
let source = ham_card(9, HAM_HISTORY);
let html = render_page(&source);
assert_eq!(page_term_lines(&html), vec![3], "{html}");
assert_eq!(
scan_terms(&source)
.iter()
.map(|t| t.line)
.collect::<Vec<_>>(),
vec![3]
);
assert!(
html.contains("<p data-mbr-line=\"1\"><strong>T1-A09"),
"{html}"
);
assert_bullets_inside_definitions(&html, &["2026-10-07 14:53 - Good"]);
let question = "**T1-A09: Who selects a Frequency Coordinator?**";
assert_eq!(
append_review(&source, 1, question, Rating::Good, at(13, 45)),
Err(FlashcardPatchError::NotATerm { line: 1 })
);
}
#[test]
fn ham_deck_appends_one_bullet_after_the_history() {
let source = ham_card(9, HAM_HISTORY);
let patch = ham_append(&source, 3, Rating::Good, 45);
assert_eq!(
patch.source,
format!("{source} * 2026-10-06 13:45 - Good\n"),
"only the new bullet, after the last one"
);
assert_eq!(patch.inserted, vec![" * 2026-10-06 13:45 - Good"]);
assert_eq!(patch.inserted_at, 16);
assert_eq!(patch.entry_line(), 16);
assert!(patch.source.contains(&format!("\n{HAM_REMEMBER}")));
assert!(
!patch.source.contains("\n\n *"),
"no blank before a bullet"
);
let html = render_page(&patch.source);
assert_eq!(page_term_lines(&html), vec![3]);
assert_bullets_inside_definitions(
&html,
&["2026-10-07 14:53 - Good", "2026-10-06 13:45 - Good"],
);
}
#[test]
fn ham_deck_first_review_creates_history_after_memory_aids_bullet() {
let source = ham_card(9, "");
let patch = ham_append(&source, 3, Rating::Hard, 45);
assert_eq!(
patch.source,
format!("{source}: ___Review History___\n * 2026-10-06 13:45 - Hard\n")
);
assert_eq!(patch.inserted_at, 14, "right after the Remember bullet");
let html = render_page(&patch.source);
assert_bullets_inside_definitions(&html, &["2026-10-06 13:45 - Hard"]);
assert!(
html.contains(
"<dd data-mbr-line=\"14\"><em><strong>Review History</strong></em>\n<ul>"
),
"{html}"
);
}
#[test]
fn ham_deck_with_several_history_entries() {
let history = concat!(
": ___Review History___\n",
" * 2026-10-01 08:00 - Again\n",
" * 2026-10-03 09:10 - Hard\n",
" * 2026-10-07 14:53 - Good\n",
);
let source = ham_card(9, history);
let patch = ham_append(&source, 3, Rating::Easy, 45);
assert_eq!(
patch.source,
format!("{source} * 2026-10-06 13:45 - Easy\n")
);
assert_eq!(patch.inserted_at, 18);
assert_bullets_inside_definitions(&render_page(&patch.source), &["13:45 - Easy"]);
}
#[test]
fn ham_deck_is_stable_over_repeated_reviews() {
for start in ["", HAM_HISTORY] {
let original = ham_card(9, start);
let mut source = original.clone();
let mut expected_tail = if start.is_empty() {
": ___Review History___\n".to_string()
} else {
start.to_string()
};
let ratings = [
Rating::Again,
Rating::Hard,
Rating::Good,
Rating::Easy,
Rating::Good,
];
for (minute, rating) in (0u32..).zip(ratings) {
let patch = ham_append(&source, 3, rating, minute);
source = patch.source;
expected_tail.push_str(&format!(
" * 2026-10-06 13:{minute:02} - {}\n",
rating.label()
));
assert_eq!(source, format!("{}{expected_tail}", ham_card(9, "")));
let html = render_page(&source);
assert_eq!(page_term_lines(&html), vec![3]);
assert_eq!(assert_bullets_inside_definitions(&html, &[]), 1);
assert_eq!(
html.matches("Review History").count(),
1,
"one history: {html}"
);
}
assert!(source.starts_with(&original[..original.len() - start.len()]));
}
}
#[test]
fn ham_deck_several_cards_in_a_row() {
let cards = [
ham_card(1, HAM_HISTORY),
ham_card(2, ""),
ham_card(3, HAM_HISTORY),
];
let mut source = cards.join("\n");
let mut cards = cards.to_vec();
for (minute, card) in [1usize, 2, 0, 1].into_iter().enumerate() {
let lines = page_term_lines(&render_page(&source));
assert_eq!(lines.len(), 3, "{source}");
assert_eq!(
lines,
scan_terms(&source)
.iter()
.map(|t| t.line)
.collect::<Vec<_>>()
);
let minute = u32::try_from(minute).unwrap();
let patch = ham_append(&source, lines[card], Rating::Good, minute);
let bullet = format!(" * 2026-10-06 13:{minute:02} - Good\n");
if !cards[card].contains("Review History") {
cards[card].push_str(": ___Review History___\n");
}
cards[card].push_str(&bullet);
source = patch.source;
assert_eq!(source, cards.join("\n"));
let html = render_page(&source);
assert_eq!(assert_bullets_inside_definitions(&html, &[]), 3, "{html}");
}
let first_len = u32::try_from(cards[0].lines().count()).unwrap();
let second_len = u32::try_from(cards[1].lines().count()).unwrap();
assert_eq!(
page_term_lines(&render_page(&source)),
vec![
1 + HAM_TERM_OFFSET,
first_len + 2 + HAM_TERM_OFFSET,
first_len + second_len + 3 + HAM_TERM_OFFSET,
]
);
}
#[test]
fn ham_deck_crlf() {
let source = ham_card(9, HAM_HISTORY).replace('\n', "\r\n");
let patch = ham_append(&source, 3, Rating::Good, 45);
assert_eq!(
patch.source,
format!("{source} * 2026-10-06 13:45 - Good\r\n")
);
let fresh = ham_card(9, "").replace('\n', "\r\n");
let patch = ham_append(&fresh, 3, Rating::Good, 45);
assert_eq!(
patch.source,
format!("{fresh}: ___Review History___\r\n * 2026-10-06 13:45 - Good\r\n")
);
assert_bullets_inside_definitions(&render_page(&patch.source), &["13:45 - Good"]);
}
#[test]
fn ham_deck_without_trailing_newline() {
let with = ham_card(9, HAM_HISTORY);
let source = with.trim_end_matches('\n');
let patch = ham_append(source, 3, Rating::Good, 45);
assert_eq!(
patch.source,
format!("{source}\n * 2026-10-06 13:45 - Good")
);
let fresh = ham_card(9, "");
let fresh = fresh.trim_end_matches('\n');
let patch = ham_append(fresh, 3, Rating::Good, 45);
assert_eq!(
patch.source,
format!("{fresh}\n: ___Review History___\n * 2026-10-06 13:45 - Good")
);
assert_bullets_inside_definitions(&render_page(&patch.source), &["13:45 - Good"]);
}
#[test]
fn ham_deck_second_card_glued_after_the_history() {
for first in [HAM_HISTORY, ""] {
let source = format!("{}{}", ham_card(1, first), ham_card(2, HAM_HISTORY));
let html = render_page(&source);
let lines = page_term_lines(&html);
assert_eq!(lines.len(), 2, "{html}");
let lists = assert_bullets_inside_definitions(&html, &[]);
assert_eq!(lists, 1, "{html}");
for &line in &lines {
let patch = ham_append(&source, line, Rating::Good, 45);
let after = render_page(&patch.source);
assert_eq!(
page_term_lines(&after).len(),
2,
"a card was lost: {}",
patch.source
);
assert!(after.contains("2026-10-06 13:45 - Good"), "{after}");
assert_eq!(
patch.source.matches(HAM_REMEMBER).count(),
2,
"{}",
patch.source
);
assert_eq!(
assert_bullets_inside_definitions(&after, &[]),
lists,
"{after}"
);
}
}
}
fn b11_card(history: &str) -> String {
format!(
concat!(
"**T1-B11: What is the maximum peak envelope power output for Technician class operators in their HF band segments?**\n",
"\n",
"A. 200 watts\n",
"B. 100 watts\n",
"C. 50 watts\n",
"D. 10 watts\n",
": **A.** 200 watts\n",
": Technician-class operators are limited to 200 watts PEP (Peak Envelope Power) on any portion of an HF band that they are authorized to use. This is an FCC regulation that must be memorized for the exam.\n",
": Also remember that FCC rule 97.313 requires stations to use the minimum transmitter power necessary to carry out the desired communications, even though the maximum allowed for Technician HF segments is 200 W PEP.\n",
": Memory aids:\n",
" * \"HF\" has 2 letters → 2-hundred watts (200 W)\n",
" * The word \"maximum\" corresponds to the largest numeric choice\n",
"{history}",
),
history = history,
)
}
const B11_DAMAGED: &str = concat!(
"**T1-B11: What is the maximum peak envelope power output for Technician class operators in their HF band segments?**\n",
"\n",
"A. 200 watts\n",
"B. 100 watts\n",
"C. 50 watts\n",
"D. 10 watts\n",
": **A.** 200 watts\n",
": Technician-class operators are limited to 200 watts PEP (Peak Envelope Power) on any portion of an HF band that they are authorized to use. This is an FCC regulation that must be memorized for the exam.\n",
": Also remember that FCC rule 97.313 requires stations to use the minimum transmitter power necessary to carry out the desired communications, even though the maximum allowed for Technician HF segments is 200 W PEP.\n",
": Memory aids:\n",
"\n",
"* \"HF\" has 2 letters → 2-hundred watts (200 W)\n",
"* The word \"maximum\" corresponds to the largest numeric choice\n",
" : ___Review History___\n",
"* 2026-10-07 14:54 - Again\n",
);
const B11_TERM: &str = "A. 200 watts";
fn b11_append(source: &str, minute: u32) -> ReviewPatch {
let patch = append_review(source, 3, B11_TERM, Rating::Again, at(14, minute))
.unwrap_or_else(|e| panic!("{e:?}\n{source}"));
assert_pure_insert(source, &patch);
patch
}
#[test]
fn b11_first_review_labels_at_column_zero_after_the_bullets() {
let source = b11_card("");
let patch = b11_append(&source, 54);
assert_eq!(
patch.inserted,
vec![": ___Review History___", " * 2026-10-06 14:54 - Again"]
);
assert_eq!(patch.inserted_at, 13, "after the second Memory aids bullet");
assert_eq!(
patch.source,
format!("{source}: ___Review History___\n * 2026-10-06 14:54 - Again\n")
);
let html = render_page(&patch.source);
assert_bullets_inside_definitions(
&html,
&["2 letters", "largest numeric", "14:54 - Again"],
);
assert!(!patch.source.contains("\n : "), "{}", patch.source);
assert!(!patch.source.contains("\n* "), "{}", patch.source);
assert!(!patch.source.contains(":\n\n"), "{}", patch.source);
}
#[test]
fn b11_repeated_reviews_never_produce_the_damage() {
for history in ["", ": ___Review History___\n * 2026-10-05 09:00 - Good\n"] {
let mut source = b11_card(history);
for minute in 0..5 {
source = b11_append(&source, minute).source;
assert!(!source.contains("\n : "), "{source}");
assert!(!source.contains("\n* "), "{source}");
assert!(!source.contains(":\n\n"), "{source}");
let html = render_page(&source);
assert_eq!(page_term_lines(&html), vec![3]);
assert_eq!(assert_bullets_inside_definitions(&html, &[]), 1, "{html}");
assert_eq!(html.matches("Review History").count(), 1, "{html}");
}
let entries = (0..5)
.map(|m| format!(" * 2026-10-06 14:{m:02} - Again\n"))
.collect::<String>();
let label = if history.is_empty() {
": ___Review History___\n"
} else {
""
};
assert_eq!(source, format!("{}{label}{entries}", b11_card(history)));
}
}
#[test]
fn b11_damaged_file_gets_a_fresh_history_inside_the_list() {
let html = render_page(B11_DAMAGED);
assert_eq!(page_term_lines(&html), vec![3, 13], "{html}");
assert_eq!(
scan_terms(B11_DAMAGED)
.iter()
.map(|t| t.line)
.collect::<Vec<_>>(),
vec![3]
);
let nested = B11_DAMAGED.lines().nth(12).unwrap();
assert_eq!(
append_review(B11_DAMAGED, 13, nested, Rating::Good, at(14, 54)),
Err(FlashcardPatchError::NotATerm { line: 13 })
);
let dl_end = html.find("</dl>").expect("a <dl>");
assert!(dl_end < html.find("2 letters").unwrap(), "{html}");
assert!(!html[..dl_end].contains("Review History"), "{html}");
let patch = b11_append(B11_DAMAGED, 54);
assert_eq!(patch.inserted_at, 11, "right after `: Memory aids:`");
assert_eq!(
patch.inserted,
vec![": ___Review History___", " * 2026-10-06 14:54 - Again"]
);
let html = render_page(&patch.source);
let dl_end = html.find("</dl>").expect("a <dl>");
assert!(html[..dl_end].contains("<em><strong>Review History</strong></em>\n<ul>"));
assert!(html[..dl_end].contains("14:54 - Again"), "{html}");
assert!(
html[dl_end..].contains("2026-10-07 14:54 - Again"),
"{html}"
);
let second = b11_append(&patch.source, 55);
assert_eq!(second.inserted, vec![" * 2026-10-06 14:55 - Again"]);
assert_eq!(second.inserted_at, 13);
}
#[derive(Debug, Clone)]
struct GenCard {
loose: bool,
glued: bool,
indent: usize,
answers: Vec<u8>,
history: Option<(char, Vec<u8>)>,
}
impl GenCard {
fn ends_in_a_fence(&self) -> bool {
self.history.is_none() && self.answers.last().is_some_and(|kind| kind % 5 == 3)
}
}
fn answer_text(kind: u8, n: usize) -> String {
match kind % 5 {
0 => format!(": answer {n}"),
1 => format!(": answer {n} line one\n and line two"),
2 => format!(": list {n}:\n * a{n}\n * b{n}"),
3 => format!(": code {n}\n\n ```\n : fake {n}\n Fake term?\n ```"),
_ => format!(": `code {n}` answer"),
}
}
fn history_text(marker: char, entries: &[u8]) -> String {
let mut text = String::from(": ___Review History___");
for (i, kind) in entries.iter().enumerate() {
let rating = ["Again", "Fail", "Hard", "Good", "Easy", "garbage"][*kind as usize % 6];
text.push_str(&format!(
"\n {marker} 2026-09-{:02} 10:00 - {rating}",
i + 1
));
}
text
}
fn gen_card() -> impl Strategy<Value = GenCard> {
(
any::<bool>(),
any::<bool>(),
0usize..4,
prop::collection::vec(0u8..5, 1..4),
prop::option::of((
prop::sample::select(vec!['*', '-']),
prop::collection::vec(0u8..6, 0..4),
)),
)
.prop_map(|(loose, glued, indent, answers, history)| GenCard {
loose,
glued,
indent,
answers,
history,
})
}
fn gen_document() -> impl Strategy<Value = (String, Vec<u32>)> {
(
any::<bool>(),
prop::collection::vec(prop::collection::vec(gen_card(), 1..4), 1..4),
any::<bool>(),
any::<bool>(),
)
.prop_map(|(frontmatter, lists, crlf, trailing_newline)| {
let mut blocks: Vec<String> = Vec::new();
if frontmatter {
blocks.push("---\ntype: flashcard\n---".to_string());
}
let mut term_index = 0usize;
for (list_index, cards) in lists.iter().enumerate() {
if list_index > 0 {
blocks.push(if list_index % 2 == 0 {
"Some prose between lists.".to_string()
} else {
"```\nFake?\n: fake\n```".to_string()
});
}
for (card_index, card) in cards.iter().enumerate() {
term_index += 1;
let separator = if card.loose { "\n\n" } else { "\n" };
let mut definitions: Vec<String> = card
.answers
.iter()
.map(|kind| answer_text(*kind, term_index))
.collect();
if let Some((marker, entries)) = &card.history {
definitions.push(history_text(*marker, entries));
}
let previous = card_index.checked_sub(1).map(|i| &cards[i]);
let glued = card.glued && previous.is_some();
let indent = if glued { card.indent } else { 0 };
let is_term = !glued
|| (previous.is_some_and(GenCard::ends_in_a_fence) && indent < 2);
let name = if is_term { "Term" } else { "Glued" };
let card_text = format!(
"{}{name} {term_index}?{separator}{}",
" ".repeat(indent),
definitions.join(separator)
);
match blocks.last_mut() {
Some(last) if glued => {
last.push('\n');
last.push_str(&card_text);
}
_ => blocks.push(card_text),
}
}
}
let mut text = blocks.join("\n\n");
if trailing_newline {
text.push('\n');
}
let terms: Vec<u32> = text
.split('\n')
.enumerate()
.filter(|(_, line)| line.trim_start().starts_with("Term "))
.map(|(i, _)| i as u32 + 1)
.collect();
if crlf {
text = text.replace('\n', "\r\n");
}
(text, terms)
})
}
fn outline(source: &str) -> Vec<(String, Vec<String>, Option<usize>)> {
scan_terms(source)
.iter()
.map(|term| {
let term_text =
line_containing(source, line_span(source, term.line).unwrap().start);
let answers = term
.definitions
.iter()
.filter(|d| !d.history)
.map(|d| source[d.range.clone()].trim_end().to_string())
.collect();
let history = term.definitions.iter().find(|d| d.history).map(|d| {
let text = &source[d.range.clone()];
text.lines()
.filter(|line| line.trim_start().starts_with(['*', '-']))
.count()
});
(term_text.to_string(), answers, history)
})
.collect()
}
proptest! {
#[test]
fn append_adds_exactly_one_history_entry(
(source, terms) in gen_document(),
pick in any::<prop::sample::Index>(),
rating in prop::sample::select(vec![Rating::Again, Rating::Hard, Rating::Good, Rating::Easy]),
) {
let before = outline(&source);
prop_assert_eq!(before.len(), terms.len(), "generator and scanner agree: {:?}", source);
let target = pick.index(terms.len());
let line = terms[target];
let expected = line_containing(&source, line_span(&source, line).unwrap().start).to_string();
let patch = append_review(&source, line, &expected, rating, at(13, 45)).unwrap();
let after = outline(&patch.source);
prop_assert_eq!(after.len(), before.len());
for (i, (b, a)) in before.iter().zip(&after).enumerate() {
prop_assert_eq!(&b.0, &a.0, "term text");
prop_assert_eq!(&b.1, &a.1, "answers of term {}", i);
if i == target {
prop_assert_eq!(a.2, Some(b.2.unwrap_or(0) + 1), "{}", patch.source);
} else {
prop_assert_eq!(b.2, a.2);
}
}
let lines: Vec<&str> = patch.source.split('\n').map(|l| l.trim_end_matches('\r')).collect();
for (offset, inserted) in patch.inserted.iter().enumerate() {
prop_assert_eq!(lines[patch.inserted_at as usize - 1 + offset], inserted.as_str());
}
prop_assert!(lines[patch.entry_line() as usize - 1].ends_with(&patch.entry));
let crlf = source.contains("\r\n");
prop_assert_eq!(crlf, patch.source.contains("\r\n"));
if crlf {
prop_assert_eq!(
patch.source.matches('\n').count(),
patch.source.matches("\r\n").count(),
"no bare LF in a CRLF file"
);
}
prop_assert_eq!(source.ends_with('\n'), patch.source.ends_with('\n'));
prop_assert_eq!(
patch.source.split('\n').count(),
source.split('\n').count() + patch.inserted.len()
);
}
#[test]
fn append_never_panics(source in "(?s).{0,200}", line in 0u32..20, expected in ".{0,20}") {
let _ = append_review(&source, line, &expected, Rating::Good, at(1, 2));
}
#[test]
fn append_on_markdownish_input_never_panics(
pieces in prop::collection::vec(
prop::sample::select(vec![
"Q?", ": a", ": ___Review History___", " * x", " - 2026-01-01 00:00 - Good",
"", "```", "> q", "- item", " ```", ":", " : nested", "\t* tab", "**Review History**",
]),
0..24,
),
line in 1u32..24,
) {
let source = pieces.join("\n");
let expected = source.split('\n').nth(line as usize - 1).unwrap_or_default().to_string();
if let Ok(patch) = append_review(&source, line, &expected, Rating::Easy, at(1, 2)) {
prop_assert!(scan_terms(&patch.source).iter().any(|t| t.line == line));
}
}
}
}