reading
This commit is contained in:
@@ -23,6 +23,36 @@ pub struct ReadingText {
|
||||
pub tokens: &'static [Token],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RuleTable {
|
||||
pub headers: &'static [&'static str],
|
||||
pub rows: &'static [&'static [&'static str]],
|
||||
pub audio_columns: &'static [RuleAudioColumn],
|
||||
pub audio_rows: &'static [&'static [&'static [&'static str]]],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RuleAudioColumn {
|
||||
pub after_text_column: usize,
|
||||
pub header: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RuleBlock {
|
||||
Paragraph(&'static str),
|
||||
Subheading(&'static str),
|
||||
Table(RuleTable),
|
||||
Bullets(&'static [&'static str]),
|
||||
Steps(&'static [&'static str]),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RuleSection {
|
||||
pub number: u8,
|
||||
pub title: &'static str,
|
||||
pub blocks: &'static [RuleBlock],
|
||||
}
|
||||
|
||||
macro_rules! entry {
|
||||
($id:literal, $ipa:literal, $hint:literal) => {
|
||||
Word {
|
||||
@@ -53,6 +83,19 @@ macro_rules! punct {
|
||||
Token::Punctuation($value)
|
||||
};
|
||||
}
|
||||
macro_rules! audio_row {
|
||||
($($file:literal),+ $(,)?) => {
|
||||
&[&[$(concat!("/rules-audio/", $file, ".mp3")),+]]
|
||||
};
|
||||
}
|
||||
macro_rules! audio_pair_row {
|
||||
($left:literal, $right:literal) => {
|
||||
&[
|
||||
&[concat!("/rules-audio/", $left, ".mp3")],
|
||||
&[concat!("/rules-audio/", $right, ".mp3")],
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
pub const DICTIONARY: &[Word] = &[
|
||||
entry!("sam", "sæm", "сэм"),
|
||||
@@ -724,6 +767,141 @@ pub const READING_TEXTS: &[ReadingText] = &[ReadingText {
|
||||
tokens: BASIC_RULES_TOKENS,
|
||||
}];
|
||||
|
||||
pub const READING_RULES_CAVEAT: &str = "Английское чтение имеет много исключений. Правила ниже помогают прочитать большинство простых слов, но незнакомое слово иногда нужно проверить в словаре.";
|
||||
|
||||
pub const READING_RULES: &[RuleSection] = &[
|
||||
RuleSection { number: 1, title: "Алфавит", blocks: &[
|
||||
RuleBlock::Table(RuleTable { headers: &["Буква", "Название", "Транскрипция"], rows: &[
|
||||
&["A", "[эй]", "[eɪ]"], &["B", "[би]", "[biː]"], &["C", "[си]", "[siː]"], &["D", "[ди]", "[diː]"], &["E", "[и]", "[iː]"], &["F", "[эф]", "[ef]"], &["G", "[джи]", "[dʒiː]"], &["H", "[эйч]", "[eɪtʃ]"], &["I", "[ай]", "[aɪ]"], &["J", "[джей]", "[dʒeɪ]"], &["K", "[кей]", "[keɪ]"], &["L", "[эл]", "[el]"], &["M", "[эм]", "[em]"],
|
||||
&["N", "[эн]", "[en]"], &["O", "[оу]", "[əʊ] / [oʊ]"], &["P", "[пи]", "[piː]"], &["Q", "[кью]", "[kjuː]"], &["R", "[а(р)]", "[ɑːr]"], &["S", "[эс]", "[es]"], &["T", "[ти]", "[tiː]"], &["U", "[ю:]", "[juː]"], &["V", "[ви]", "[viː]"], &["W", "[дабл-ю]", "[ˈdʌbəljuː]"], &["X", "[экс]", "[eks]"], &["Y", "[уай]", "[waɪ]"], &["Z", "[зи] / [зэд]", "[ziː] / [zed]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("a"), audio_row!("b"), audio_row!("c"), audio_row!("d"), audio_row!("e"), audio_row!("f"), audio_row!("g"), audio_row!("h"), audio_row!("i"), audio_row!("j"), audio_row!("k"), audio_row!("l"), audio_row!("m"),
|
||||
audio_row!("n"), audio_row!("o"), audio_row!("p"), audio_row!("q"), audio_row!("r"), audio_row!("s"), audio_row!("t"), audio_row!("u"), audio_row!("v"), audio_row!("w"), audio_row!("x"), audio_row!("y"), audio_row!("z"),
|
||||
] }),
|
||||
] },
|
||||
RuleSection { number: 2, title: "Краткие гласные (закрытый слог)", blocks: &[
|
||||
RuleBlock::Paragraph("В закрытом слоге гласная обычно читается кратко. Закрытый слог обычно заканчивается согласной: `cat`, `bed`."),
|
||||
RuleBlock::Table(RuleTable { headers: &["Буква", "Звук", "Примеры"], rows: &[
|
||||
&["a", "[æ]", "`cat` [kæt], `map` [mæp]"], &["e", "[e]", "`bed` [bed], `pen` [pen]"], &["i", "[ɪ]", "`sit` [sɪt], `big` [bɪɡ]"], &["o", "[ɑ]", "`hot` [hɑt], `box` [bɑks]"], &["u", "[ʌ]", "`cup` [kʌp], `sun` [sʌn]"], &["y", "[ɪ]", "`gym` [dʒɪm], `system` [ˈsɪstəm]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио примеров" }], audio_rows: &[
|
||||
audio_row!("cat", "map"), audio_row!("bed", "pen"), audio_row!("sit", "big"), audio_row!("hot", "box"), audio_row!("cup", "sun"), audio_row!("gym", "system"),
|
||||
] }),
|
||||
] },
|
||||
RuleSection { number: 3, title: "Долгие гласные (открытый слог)", blocks: &[
|
||||
RuleBlock::Paragraph("Долгий звук часто совпадает с названием буквы. В таблице использовано американское произношение."),
|
||||
RuleBlock::Table(RuleTable { headers: &["Буква", "Звук", "Примеры"], rows: &[
|
||||
&["a", "[eɪ]", "`name` [neɪm], `late` [leɪt]"], &["e", "[i:]", "`these` [ðiːz], `theme` [θiːm]"], &["i", "[aɪ]", "`time` [taɪm], `five` [faɪv]"], &["o", "[oʊ]", "`home` [hoʊm], `note` [noʊt]"], &["u", "[ju:] или [u:]", "`cute` [kjuːt], `rule` [ruːl]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио примеров" }], audio_rows: &[
|
||||
audio_row!("name", "late"), audio_row!("these", "theme"), audio_row!("time", "five"), audio_row!("home", "note"), audio_row!("cute", "rule"),
|
||||
] }),
|
||||
] },
|
||||
RuleSection { number: 4, title: "Немая `e`", blocks: &[
|
||||
RuleBlock::Paragraph("В модели `гласная + согласная + e` последняя `e` обычно не читается, а первая гласная становится долгой (слог стал открытым). В таблице использовано американское произношение."),
|
||||
RuleBlock::Table(RuleTable { headers: &["Короткий вариант", "Длинный вариант"], rows: &[
|
||||
&["`cap` [kæp]", "`cape` [keɪp]"], &["`kit` [kɪt]", "`kite` [kaɪt]"], &["`hop` [hɑp]", "`hope` [hoʊp]"], &["`cub` [kʌb]", "`cube` [kjuːb]"],
|
||||
], audio_columns: &[
|
||||
RuleAudioColumn { after_text_column: 1, header: "Аудио" },
|
||||
RuleAudioColumn { after_text_column: 2, header: "Аудио" },
|
||||
], audio_rows: &[
|
||||
audio_pair_row!("cap", "cape"), audio_pair_row!("kit", "kite"), audio_pair_row!("hop", "hope"), audio_pair_row!("cub", "cube"),
|
||||
] }),
|
||||
RuleBlock::Subheading("Исключения"),
|
||||
RuleBlock::Table(RuleTable { headers: &["Слово", "Транскрипция"], rows: &[
|
||||
&["`have`", "[hæv]"], &["`give`", "[ɡɪv]"], &["`love`", "[lʌv]"], &["`move`", "[muːv]"], &["`come`", "[kʌm]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 2, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("have"), audio_row!("give"), audio_row!("love"), audio_row!("move"), audio_row!("come"),
|
||||
] }),
|
||||
] },
|
||||
RuleSection { number: 5, title: "Сочетания гласных", blocks: &[
|
||||
RuleBlock::Subheading("Сочетания с предсказуемым чтением"),
|
||||
RuleBlock::Paragraph("Эти сочетания обычно читаются одинаково в большинстве знакомых слов:"),
|
||||
RuleBlock::Table(RuleTable { headers: &["Сочетание", "Звук", "Примеры"], rows: &[
|
||||
&["`ee`", "[i:]", "`see` [si:], `green` [ɡri:n]"], &["`ai` в середине слова", "[eɪ]", "`rain` [reɪn]"], &["`ay` в конце слова", "[eɪ]", "`day` [deɪ]"], &["`oa`", "[oʊ]", "`boat` [boʊt], `road` [roʊd]"], &["`oi` в середине слова", "[ɔɪ]", "`coin` [kɔɪn]"], &["`oy` в конце слова", "[ɔɪ]", "`boy` [bɔɪ]"], &["`igh`", "[aɪ]", "`night` [naɪt], `light` [laɪt]"], &["`au`, `aw`", "[ɔ:]", "`autumn` [ˈɔ:təm], `saw` [sɔ:]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("see", "green"), audio_row!("rain"), audio_row!("day"), audio_row!("boat", "road"), audio_row!("coin"), audio_row!("boy"), audio_row!("night", "light"), audio_row!("autumn", "saw"),
|
||||
] }),
|
||||
] },
|
||||
RuleSection { number: 6, title: "Основные согласные", blocks: &[
|
||||
RuleBlock::Paragraph("Большинство согласных читаются стабильно: `b` — [b] (`book`), `d` — [d] (`dog`), `f` — [f] (`fish`), `h` — [h] (`house`), `k` — [k] (`kite`), `l` — [l] (`leg`), `m` — [m] (`man`), `n` — [n] (`name`), `p` — [p] (`pen`), `r` — английский [ɹ] (`red`), `t` — [t] (`ten`), `v` — [v] (`van`), `z` — [z] (`zoo`). Английский [ɹ] не нужно раскатывать, как русский [р]."),
|
||||
] },
|
||||
RuleSection { number: 7, title: "Буква `c`", blocks: &[
|
||||
RuleBlock::Bullets(&["перед `e`, `i`, `y` обычно читается [s]: `city`, `cent`, `cycle`;", "перед `a`, `o`, `u` обычно читается [k]: `cat`, `cold`, `cup`."]),
|
||||
RuleBlock::Paragraph("Исключения: `school`, `muscle`, `ocean`."),
|
||||
] },
|
||||
RuleSection { number: 8, title: "Буква `g`", blocks: &[
|
||||
RuleBlock::Bullets(&["перед `e`, `i`, `y` часто читается [dʒ]: `giant`, `gym`, `page`;", "перед `a`, `o`, `u` обычно читается [g]: `game`, `go`, `gum`."]),
|
||||
RuleBlock::Paragraph("Исключения: `get`, `give`, `girl`, `gear`."),
|
||||
] },
|
||||
RuleSection { number: 9, title: "Сочетания согласных", blocks: &[
|
||||
RuleBlock::Table(RuleTable { headers: &["Сочетание", "Звук", "Примеры"], rows: &[
|
||||
&["`ch`", "[tʃ]", "`chair` [tʃer], `chicken` [ˈtʃɪkən]"], &["`sh`", "[ʃ]", "`ship` [ʃɪp], `fish` [fɪʃ]"], &["`ph`", "[f]", "`phone` [foʊn], `photo` [ˈfoʊtoʊ]"], &["`th`", "[θ]", "`think` [θɪŋk], `three` [θriː]"], &["`th`", "[ð]", "`this` [ðɪs], `mother` [ˈmʌðər]"], &["`wh`", "[w]", "`what` [wʌt], `when` [wen]"], &["`ck`", "[k]", "`back` [bæk], `black` [blæk]"], &["`ng`", "[ŋ]", "`sing` [sɪŋ], `long` [lɔŋ]"], &["`qu`", "[kw]", "`queen` [kwiːn], `quick` [kwɪk]"], &["`tch`", "[tʃ]", "`match` [mætʃ], `catch` [kætʃ]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("chair", "chicken"), audio_row!("ship", "fish"), audio_row!("phone", "photo"), audio_row!("think", "three"), audio_row!("this", "mother"), audio_row!("what", "when"), audio_row!("back", "black"), audio_row!("sing", "long"), audio_row!("queen", "quick"), audio_row!("match", "catch"),
|
||||
] }),
|
||||
] },
|
||||
RuleSection { number: 10, title: "Буквы `x` и `y`", blocks: &[
|
||||
RuleBlock::Subheading("`x`"),
|
||||
RuleBlock::Table(RuleTable { headers: &["Положение или случай", "Звук", "Примеры"], rows: &[
|
||||
&["внутри и в конце слова", "[ks]", "`box` [bɑks], `six` [sɪks]"], &["в начале слов греческого происхождения", "[z]", "`xylophone` [ˈzaɪləfoʊn]"], &["в некоторых словах исключениях", "[gz]", "`exam` [ɪɡˈzæm], `exact` [ɪɡˈzækt]"], &["исключение `X-ray`", "[eks]", "`X-ray` [ˈɛksreɪ]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 3, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("box", "six"), audio_row!("xylophone"), audio_row!("exam", "exact"), audio_row!("x-ray"),
|
||||
] }),
|
||||
RuleBlock::Subheading("`y`"),
|
||||
RuleBlock::Paragraph("Буква `y` может вести себя как согласная или как гласная:"),
|
||||
RuleBlock::Table(RuleTable { headers: &["Роль", "Положение", "Звук", "Примеры"], rows: &[
|
||||
&["Согласная", "в начале слова", "[j]", "`yes` [jes], `yellow` [ˈjeloʊ]"], &["Гласная", "в конце односложного слова", "[aɪ]", "`my` [maɪ], `cry` [kraɪ]"], &["Гласная", "в конце многосложного слова", "[i]", "`happy` [ˈhæpi], `city` [ˈsɪti]"], &["Гласная", "внутри слова, часто", "[ɪ]", "`gym` [dʒɪm], `system` [ˈsɪstəm]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 4, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("yes", "yellow"), audio_row!("my", "cry"), audio_row!("happy", "city"), audio_row!("gym", "system"),
|
||||
] }),
|
||||
RuleBlock::Paragraph("Итак, в `yes` буква `y` — согласная, а в `my`, `happy`, `gym` и `system` — гласная."),
|
||||
] },
|
||||
RuleSection { number: 11, title: "Немые буквы: применимые шаблоны", blocks: &[
|
||||
RuleBlock::Table(RuleTable { headers: &["Шаблон", "Правило", "Примеры"], rows: &[
|
||||
&["`kn-` в начале слова", "`k` не читается", "`know` [noʊ], `knife` [naɪf]"], &["`wr-` в начале слова", "`w` не читается", "`wrap` [ræp], `wrist` [rɪst]"], &["`-mb` в конце слова", "`b` не читается", "`lamb` [læm], `thumb` [θʌm]"], &["`gn-` в начале слова", "`g` не читается", "`gnome` [noʊm], `gnat` [næt]"], &["`-ould`", "`l` не читается", "`could` [kʊd], `would` [wʊd], `should` [ʃʊd]"], &["`-sten`", "`t` не читается", "`listen` [ˈlɪsən], `fasten` [ˈfæsən]"], &["`-stle`", "`t` не читается", "`castle` [ˈkæsəl], `whistle` [ˈwɪsəl]"],
|
||||
], audio_columns: &[], audio_rows: &[] }),
|
||||
] },
|
||||
RuleSection { number: 12, title: "Окончания: применимые шаблоны", blocks: &[
|
||||
RuleBlock::Table(RuleTable { headers: &["Шаблон", "Звук", "Примеры"], rows: &[
|
||||
&["`-tion`", "[ʃən]", "`station`, `nation`, `information`"], &["`-stion`", "[tʃən]", "`question`, `suggestion`"], &["`-cian`", "[ʃən]", "`musician`, `politician`"], &["`-ssion`", "[ʃən]", "`mission`, `expression`"],
|
||||
], audio_columns: &[], audio_rows: &[] }),
|
||||
RuleBlock::Paragraph("Окончание `-sion` без второго `s` не вынесено в правило: его чтение нужно проверять в словаре и запоминать со словом."),
|
||||
] },
|
||||
RuleSection { number: 13, title: "Окончания `-s` и `-es`", blocks: &[
|
||||
RuleBlock::Table(RuleTable { headers: &["Произношение", "Когда", "Примеры"], rows: &[
|
||||
&["[s]", "после глухих согласных, кроме шипящих", "`cats`, `books`, `cups`"], &["[z]", "после звонких звуков и гласных", "`dogs`, `pens`, `plays`"], &["[ɪz]", "после шипящих звуков", "`boxes`, `washes`, `buses`"],
|
||||
], audio_columns: &[], audio_rows: &[] }),
|
||||
] },
|
||||
RuleSection { number: 14, title: "Окончания `-er` и `-or`", blocks: &[
|
||||
RuleBlock::Paragraph("В безударном положении они часто читаются как [ə]:"),
|
||||
RuleBlock::Bullets(&["`teacher` — [ˈti:tʃə];", "`doctor` — [ˈdɒktə];", "`actor` — [ˈæktə]."]),
|
||||
RuleBlock::Paragraph("В американском английском конечный `r` обычно произносится."),
|
||||
] },
|
||||
RuleSection { number: 15, title: "Безударная гласная", blocks: &[
|
||||
RuleBlock::Paragraph("В безударном слоге гласная часто превращается в нейтральный звук [ə]:"),
|
||||
RuleBlock::Bullets(&["`about` — [əˈbaʊt];", "`banana` — [bəˈnɑ:nə];", "`teacher` — [ˈti:tʃə]."]),
|
||||
] },
|
||||
RuleSection { number: 16, title: "Деление на слоги", blocks: &[
|
||||
RuleBlock::Bullets(&["один согласный между гласными часто относится к следующему слогу: `pa-per`;", "две согласные обычно разделяются: `win-ter`;", "конечное `le` образует отдельный слог: `ta-ble`, `lit-tle`."]),
|
||||
] },
|
||||
RuleSection { number: 17, title: "Ударение", blocks: &[
|
||||
RuleBlock::Paragraph("Ударный слог произносится сильнее, дольше и выше. В таблицах используется американское произношение."),
|
||||
RuleBlock::Table(RuleTable { headers: &["Слово", "Транскрипция"], rows: &[
|
||||
&["`TAble`", "[ˈteɪbəl]"], &["`WINdow`", "[ˈwɪndoʊ]"], &["`beGIN`", "[bɪˈɡɪn]"], &["`imPORtant`", "[ɪmˈpɔrtənt]"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 2, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("table"), audio_row!("window"), audio_row!("begin"), audio_row!("important"),
|
||||
] }),
|
||||
RuleBlock::Paragraph("Ударение может менять значение или часть речи:"),
|
||||
RuleBlock::Table(RuleTable { headers: &["Значение или роль", "Слово", "Транскрипция", "Контекст в аудио"], rows: &[
|
||||
&["запись", "`REcord`", "[ˈrɛkɚd]", "`a record`"], &["записывать", "`reCORD`", "[rɪˈkɔrd]", "`record it`"], &["подарок / настоящий", "`PREsent`", "[ˈprɛzənt]", "`a present`"], &["представлять", "`preSENT`", "[prɪˈzɛnt]", "`present it`"],
|
||||
], audio_columns: &[RuleAudioColumn { after_text_column: 4, header: "Аудио" }], audio_rows: &[
|
||||
audio_row!("record-noun"), audio_row!("record-verb"), audio_row!("present-noun"), audio_row!("present-verb"),
|
||||
] }),
|
||||
] },
|
||||
RuleSection { number: 18, title: "Алгоритм чтения нового слова", blocks: &[
|
||||
RuleBlock::Steps(&["Найди гласные и согласные.", "Определи, закрытый ли это слог.", "Проверь правило немой `e`.", "Найди сочетания гласных и согласных.", "Проверь, нет ли немых букв.", "Раздели длинное слово на слоги.", "Поставь ударение.", "Проверь слово в словаре, если чтение не подходит."]),
|
||||
] },
|
||||
];
|
||||
|
||||
pub fn word_by_id(id: &str) -> Option<&'static Word> {
|
||||
DICTIONARY.iter().find(|word| word.id == id)
|
||||
}
|
||||
@@ -828,4 +1006,193 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_rules_cover_all_source_sections_with_source_derived_content() {
|
||||
let source = include_str!("../../english/ex/theme-13-reading-rules/teoria-from-zero.md");
|
||||
let source_audio_paths = source
|
||||
.split("src=\"audio/")
|
||||
.skip(1)
|
||||
.map(|fragment| {
|
||||
let (filename, _) = fragment
|
||||
.split_once('"')
|
||||
.expect("source audio path must end with a quote");
|
||||
format!("/rules-audio/{filename}")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let expected_titles = [
|
||||
"Алфавит",
|
||||
"Краткие гласные (закрытый слог)",
|
||||
"Долгие гласные (открытый слог)",
|
||||
"Немая `e`",
|
||||
"Сочетания гласных",
|
||||
"Основные согласные",
|
||||
"Буква `c`",
|
||||
"Буква `g`",
|
||||
"Сочетания согласных",
|
||||
"Буквы `x` и `y`",
|
||||
"Немые буквы: применимые шаблоны",
|
||||
"Окончания: применимые шаблоны",
|
||||
"Окончания `-s` и `-es`",
|
||||
"Окончания `-er` и `-or`",
|
||||
"Безударная гласная",
|
||||
"Деление на слоги",
|
||||
"Ударение",
|
||||
"Алгоритм чтения нового слова",
|
||||
];
|
||||
let expected_audio_schemas = vec![
|
||||
(
|
||||
1,
|
||||
vec!["Буква", "Название", "Транскрипция"],
|
||||
vec![(3, "Аудио")],
|
||||
),
|
||||
(
|
||||
2,
|
||||
vec!["Буква", "Звук", "Примеры"],
|
||||
vec![(3, "Аудио примеров")],
|
||||
),
|
||||
(
|
||||
3,
|
||||
vec!["Буква", "Звук", "Примеры"],
|
||||
vec![(3, "Аудио примеров")],
|
||||
),
|
||||
(
|
||||
4,
|
||||
vec!["Короткий вариант", "Длинный вариант"],
|
||||
vec![(1, "Аудио"), (2, "Аудио")],
|
||||
),
|
||||
(4, vec!["Слово", "Транскрипция"], vec![(2, "Аудио")]),
|
||||
(5, vec!["Сочетание", "Звук", "Примеры"], vec![(3, "Аудио")]),
|
||||
(9, vec!["Сочетание", "Звук", "Примеры"], vec![(3, "Аудио")]),
|
||||
(
|
||||
10,
|
||||
vec!["Положение или случай", "Звук", "Примеры"],
|
||||
vec![(3, "Аудио")],
|
||||
),
|
||||
(
|
||||
10,
|
||||
vec!["Роль", "Положение", "Звук", "Примеры"],
|
||||
vec![(4, "Аудио")],
|
||||
),
|
||||
(17, vec!["Слово", "Транскрипция"], vec![(2, "Аудио")]),
|
||||
(
|
||||
17,
|
||||
vec![
|
||||
"Значение или роль",
|
||||
"Слово",
|
||||
"Транскрипция",
|
||||
"Контекст в аудио",
|
||||
],
|
||||
vec![(4, "Аудио")],
|
||||
),
|
||||
];
|
||||
|
||||
assert_eq!(READING_RULES.len(), expected_titles.len());
|
||||
assert_eq!(
|
||||
READING_RULES
|
||||
.iter()
|
||||
.map(|section| section.number)
|
||||
.collect::<Vec<_>>(),
|
||||
(1..=18).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
READING_RULES
|
||||
.iter()
|
||||
.map(|section| section.title)
|
||||
.collect::<Vec<_>>(),
|
||||
expected_titles
|
||||
);
|
||||
assert_eq!(
|
||||
READING_RULES_CAVEAT,
|
||||
"Английское чтение имеет много исключений. Правила ниже помогают прочитать большинство простых слов, но незнакомое слово иногда нужно проверить в словаре."
|
||||
);
|
||||
assert!(source.contains(READING_RULES_CAVEAT));
|
||||
|
||||
let mut table_count = 0;
|
||||
let mut row_count = 0;
|
||||
let mut step_count = 0;
|
||||
let mut rule_audio_paths = Vec::new();
|
||||
let mut actual_audio_schemas = Vec::new();
|
||||
for section in READING_RULES {
|
||||
assert!(
|
||||
source.contains(section.title),
|
||||
"missing title: {}",
|
||||
section.title
|
||||
);
|
||||
for block in section.blocks {
|
||||
match block {
|
||||
RuleBlock::Paragraph(text) | RuleBlock::Subheading(text) => {
|
||||
assert!(source.contains(text), "missing text: {text}");
|
||||
}
|
||||
RuleBlock::Bullets(items) | RuleBlock::Steps(items) => {
|
||||
for item in *items {
|
||||
assert!(source.contains(item), "missing list item: {item}");
|
||||
}
|
||||
if let RuleBlock::Steps(items) = block {
|
||||
step_count += items.len();
|
||||
}
|
||||
}
|
||||
RuleBlock::Table(table) => {
|
||||
table_count += 1;
|
||||
row_count += table.rows.len();
|
||||
for header in table.headers {
|
||||
assert!(source.contains(header), "missing table header: {header}");
|
||||
}
|
||||
for row in table.rows {
|
||||
for cell in *row {
|
||||
assert!(source.contains(cell), "missing table cell: {cell}");
|
||||
}
|
||||
}
|
||||
if table.audio_columns.is_empty() {
|
||||
assert!(table.audio_rows.is_empty());
|
||||
} else {
|
||||
actual_audio_schemas.push((
|
||||
section.number,
|
||||
table.headers.to_vec(),
|
||||
table
|
||||
.audio_columns
|
||||
.iter()
|
||||
.map(|column| (column.after_text_column, column.header))
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
assert_eq!(table.audio_rows.len(), table.rows.len());
|
||||
for audio_row in table.audio_rows {
|
||||
assert_eq!(audio_row.len(), table.audio_columns.len());
|
||||
for paths in *audio_row {
|
||||
assert!(!paths.is_empty());
|
||||
for path in *paths {
|
||||
let source_path = format!(
|
||||
"src=\"audio/{}\"",
|
||||
path.trim_start_matches("/rules-audio/")
|
||||
);
|
||||
assert!(
|
||||
source.contains(&source_path),
|
||||
"missing source audio: {path}"
|
||||
);
|
||||
let asset = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("public")
|
||||
.join(path.trim_start_matches('/'));
|
||||
assert!(
|
||||
asset.is_file(),
|
||||
"missing rules audio: {}",
|
||||
asset.display()
|
||||
);
|
||||
rule_audio_paths.push((*path).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rule_audio_paths.sort_unstable();
|
||||
let mut source_audio_paths = source_audio_paths;
|
||||
source_audio_paths.sort_unstable();
|
||||
assert_eq!(rule_audio_paths, source_audio_paths);
|
||||
assert_eq!(actual_audio_schemas, expected_audio_schemas);
|
||||
assert_eq!(table_count, 14);
|
||||
assert_eq!(row_count, 94);
|
||||
assert_eq!(step_count, 8);
|
||||
}
|
||||
}
|
||||
|
||||
+150
-9
@@ -1,6 +1,9 @@
|
||||
mod data;
|
||||
|
||||
use data::{word_by_id, ReadingText, Token, Word, DICTIONARY, READING_TEXTS};
|
||||
use data::{
|
||||
word_by_id, ReadingText, RuleBlock, RuleTable, Token, Word, DICTIONARY, READING_RULES,
|
||||
READING_RULES_CAVEAT, READING_TEXTS,
|
||||
};
|
||||
use leptos::ev::KeyboardEvent;
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
@@ -38,6 +41,13 @@ fn App() -> impl IntoView {
|
||||
audio_state.set(AudioState::Ready);
|
||||
selected_id.set(Some(id));
|
||||
};
|
||||
let clear_selection = move |_| {
|
||||
if let Some(audio) = audio_ref.get() {
|
||||
let _ = audio.pause();
|
||||
}
|
||||
audio_state.set(AudioState::Ready);
|
||||
selected_id.set(None);
|
||||
};
|
||||
let activate_key = move |event: KeyboardEvent, id: &'static str| {
|
||||
if event.key() == "Enter" || event.key() == " " {
|
||||
event.prevent_default();
|
||||
@@ -74,15 +84,27 @@ fn App() -> impl IntoView {
|
||||
</header>
|
||||
|
||||
<div class="reader-workspace">
|
||||
<section class="detail-panel" aria-live="polite" aria-labelledby="detail-heading">
|
||||
{move || selected_id.get().and_then(word_by_id).map(|word| detail_view(word, audio_ref, audio_state, toggle_audio)).unwrap_or_else(|| view! {
|
||||
<div class="detail-empty">
|
||||
<p class="detail-kicker">"Словарная карточка"</p>
|
||||
<h2 id="detail-heading">"Выберите слово в тексте"</h2>
|
||||
<p>"Карточка покажет написание, американское произношение и русскую подсказку."</p>
|
||||
<aside class="study-sidebar" aria-labelledby="rules-heading">
|
||||
<section class="detail-panel" aria-live="polite" aria-labelledby="detail-heading">
|
||||
{move || selected_id.get().and_then(word_by_id).map(|word| detail_view(word, audio_ref, audio_state, toggle_audio, clear_selection)).unwrap_or_else(|| view! {
|
||||
<div class="detail-empty">
|
||||
<p class="detail-kicker">"Словарная карточка"</p>
|
||||
<h2 id="detail-heading">"Выберите слово в тексте"</h2>
|
||||
<p>"Карточка покажет написание, американское произношение и русскую подсказку."</p>
|
||||
</div>
|
||||
}.into_any())}
|
||||
</section>
|
||||
<section class="rules-panel" aria-labelledby="rules-heading">
|
||||
<div class="rules-heading">
|
||||
<p class="section-number">"02"</p>
|
||||
<div>
|
||||
<h2 id="rules-heading">"Правила чтения"</h2>
|
||||
<p>"Полный справочник урока: правила, исключения и алгоритм."</p>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any())}
|
||||
</section>
|
||||
<div class="rules-scroll">{rules_view()}</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="reading-card" aria-labelledby="reading-heading">
|
||||
<div class="section-heading">
|
||||
@@ -144,6 +166,121 @@ fn App() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
fn rules_view() -> AnyView {
|
||||
view! {
|
||||
<p class="rules-caveat">{render_rule_text(READING_RULES_CAVEAT)}</p>
|
||||
{READING_RULES.iter().map(|section| {
|
||||
let section_id = format!("rule-section-{}", section.number);
|
||||
let heading_id = section_id.clone();
|
||||
view! {
|
||||
<section class="rule-section" aria-labelledby=section_id>
|
||||
<div class="rule-heading">
|
||||
<p class="rule-number">{format!("{:02}", section.number)}</p>
|
||||
<h3 id=heading_id>{render_rule_text(section.title)}</h3>
|
||||
</div>
|
||||
{section.blocks.iter().map(render_rule_block).collect_view()}
|
||||
</section>
|
||||
}
|
||||
}).collect_view()}
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_rule_block(block: &RuleBlock) -> AnyView {
|
||||
match block {
|
||||
RuleBlock::Paragraph(text) => view! {
|
||||
<p class="rule-copy">{render_rule_text(text)}</p>
|
||||
}
|
||||
.into_any(),
|
||||
RuleBlock::Subheading(text) => view! {
|
||||
<h4 class="rule-subheading">{render_rule_text(text)}</h4>
|
||||
}
|
||||
.into_any(),
|
||||
RuleBlock::Table(table) => rule_table_view(*table),
|
||||
RuleBlock::Bullets(items) => view! {
|
||||
<ul class="rule-list">
|
||||
{items.iter().map(|item| view! { <li>{render_rule_text(item)}</li> }).collect_view()}
|
||||
</ul>
|
||||
}
|
||||
.into_any(),
|
||||
RuleBlock::Steps(items) => view! {
|
||||
<ol class="rule-list rule-steps">
|
||||
{items.iter().map(|item| view! { <li>{render_rule_text(item)}</li> }).collect_view()}
|
||||
</ol>
|
||||
}
|
||||
.into_any(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rule_table_view(table: RuleTable) -> AnyView {
|
||||
view! {
|
||||
<div class="rule-table-wrap">
|
||||
<table class="rule-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{table.headers.iter().enumerate().map(|(text_index, header)| view! {
|
||||
<th scope="col">{render_rule_text(header)}</th>
|
||||
{table.audio_columns.iter().filter(|column| column.after_text_column == text_index + 1).map(|column| view! {
|
||||
<th scope="col">{render_rule_text(column.header)}</th>
|
||||
}).collect_view()}
|
||||
}).collect_view()}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.rows.iter().enumerate().map(|(row_index, row)| {
|
||||
let audio_cells = table.audio_rows.get(row_index).copied().unwrap_or(&[]);
|
||||
view! {
|
||||
<tr>
|
||||
{row.iter().enumerate().map(|(text_index, cell)| view! {
|
||||
<td>{render_rule_text(cell)}</td>
|
||||
{table.audio_columns.iter().enumerate().filter(|(_, column)| column.after_text_column == text_index + 1).map(|(audio_index, _)| {
|
||||
let paths = audio_cells.get(audio_index).copied().unwrap_or(&[]);
|
||||
rule_audio_cell_view(paths)
|
||||
}).collect_view()}
|
||||
}).collect_view()}
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn rule_audio_cell_view(paths: &'static [&'static str]) -> AnyView {
|
||||
view! {
|
||||
<td class="rule-audio-cell">
|
||||
{paths.iter().map(|path| view! {
|
||||
<audio
|
||||
class="rule-audio"
|
||||
controls=true
|
||||
preload="none"
|
||||
src=*path
|
||||
aria-label=format!("Аудио примера: {}", path.trim_start_matches("/rules-audio/").trim_end_matches(".mp3"))
|
||||
>
|
||||
"Ваш браузер не поддерживает аудио."
|
||||
</audio>
|
||||
}).collect_view()}
|
||||
</td>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_rule_text(text: &'static str) -> AnyView {
|
||||
text.split('`')
|
||||
.enumerate()
|
||||
.map(|(index, fragment)| {
|
||||
if index % 2 == 0 {
|
||||
view! { {fragment} }.into_any()
|
||||
} else {
|
||||
view! { <code>{fragment}</code> }.into_any()
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_reading_text(
|
||||
reading_text: &'static ReadingText,
|
||||
selected_id: RwSignal<Option<&'static str>>,
|
||||
@@ -186,6 +323,7 @@ fn detail_view(
|
||||
audio_ref: NodeRef<leptos::html::Audio>,
|
||||
audio_state: RwSignal<AudioState>,
|
||||
toggle_audio: impl Fn(leptos::ev::MouseEvent) + Copy + 'static,
|
||||
clear_selection: impl Fn(leptos::ev::MouseEvent) + Copy + 'static,
|
||||
) -> AnyView {
|
||||
view! {
|
||||
<div class="detail-content">
|
||||
@@ -215,6 +353,9 @@ fn detail_view(
|
||||
{move || audio_button_text(audio_state.get())}
|
||||
</button>
|
||||
<p class="audio-note" aria-live="polite">{move || audio_status_text(audio_state.get())}</p>
|
||||
<button class="clear-selection" type="button" on:click=clear_selection>
|
||||
"Сбросить выбор слова"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user