stv_rs/
parse.rs

1// Copyright 2023 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Module to parse STV ballot files.
16
17use crate::types::{Ballot, Candidate, Election};
18use log::{info, trace, warn};
19use regex::Regex;
20use std::collections::{HashMap, HashSet};
21use std::io::BufRead;
22
23/// Options to control the parsing process.
24pub struct ParsingOptions {
25    /// Whether to remove withdrawn candidates from the ballots they appear in.
26    pub remove_withdrawn_candidates: bool,
27    /// Whether to remove ballots that rank no candidate. When
28    /// `remove_withdrawn_candidates` is true, this also removes ballots
29    /// that only rank withdrawn candidates.
30    pub remove_empty_ballots: bool,
31    /// Whether to optimize the layout of ballots in memory. This may entail
32    /// sorting the ballots.
33    pub optimize_layout: bool,
34}
35
36// TODO: Remove unwrap()s.
37/// Parses a ballot file into an election input.
38pub fn parse_election(
39    input: impl BufRead,
40    options: ParsingOptions,
41) -> Result<Election, Box<dyn std::error::Error>> {
42    let re_count = Regex::new(r"^([0-9]+) ([0-9]+)$").unwrap();
43    let re_option = Regex::new(r"^\[[a-z]+(?: [a-z][a-z0-9]*)+\]$").unwrap();
44    let re_ballot = Regex::new(r"^([0-9]+)((?: [a-z0-9=]*)*) 0$").unwrap();
45
46    info!(
47        "Optimizing the in-memory layout of ballots is {}",
48        if options.optimize_layout {
49            "enabled"
50        } else {
51            "disabled"
52        }
53    );
54
55    let mut lines = input.lines().peekable();
56
57    let header = lines.next().unwrap().unwrap();
58    let cap_count = re_count.captures(&header).unwrap();
59    let num_candidates = cap_count.get(1).unwrap().as_str().parse::<usize>().unwrap();
60    let num_seats = cap_count.get(2).unwrap().as_str().parse::<usize>().unwrap();
61
62    info!("{num_seats} seats / {num_candidates} candidates");
63
64    // Parse the options
65    let mut nicknames = None;
66    let mut withdrawn: HashSet<String> = HashSet::new();
67    let mut tie = None;
68    while let Some(line) = lines.peek() {
69        let line = line.as_ref().unwrap();
70        if !re_option.is_match(line) {
71            break;
72        }
73
74        let mut items = line[1..line.len() - 1].split(' ');
75        let title = items.next().unwrap();
76
77        match title {
78            "nick" => {
79                let values = items.map(|x| x.to_owned()).collect::<Vec<String>>();
80                info!("Nicknames: {values:?}");
81                nicknames = Some(values);
82            }
83            "withdrawn" => {
84                let values = items.map(|x| x.to_owned()).collect::<Vec<String>>();
85                info!("Withdrawn: {values:?}");
86                withdrawn = values.into_iter().collect::<HashSet<String>>();
87            }
88            "tie" => {
89                let values = items.map(|x| x.to_owned()).collect::<Vec<String>>();
90                info!("Tie-break order: {values:?}");
91                tie = Some(values);
92            }
93            _ => warn!("Unknown option: {title}"),
94        }
95
96        lines.next();
97    }
98
99    let nicknames: Vec<String> = nicknames.unwrap();
100    info!("Candidates (by nickname): {nicknames:?}");
101    assert_eq!(nicknames.len(), num_candidates);
102
103    let hash_nicknames: HashMap<&str, usize> = nicknames
104        .iter()
105        .enumerate()
106        .map(|(i, c)| (c.as_str(), i))
107        .collect();
108
109    let tie_order: HashMap<usize, usize> = {
110        match tie {
111            None => (0..num_candidates).map(|i| (i, i)).collect(),
112            Some(tie) => {
113                assert_eq!(
114                    tie.len(),
115                    num_candidates,
116                    "Tie-break order must mention all candidates"
117                );
118                let mut tie_order = HashMap::new();
119                for (i, c) in tie.iter().enumerate() {
120                    let id = *hash_nicknames.get(c.as_str()).unwrap();
121                    assert!(
122                        tie_order.insert(id, i).is_none(),
123                        "Candidate mentioned twice in tie order: {c}",
124                    );
125                }
126                tie_order
127            }
128        }
129    };
130
131    let mut ballots = Vec::new();
132    loop {
133        let line = lines.next().unwrap().unwrap();
134        if line == "0" {
135            break;
136        }
137        match re_ballot.captures(&line) {
138            Some(cap_ballots) => {
139                let count = cap_ballots
140                    .get(1)
141                    .unwrap()
142                    .as_str()
143                    .parse::<usize>()
144                    .unwrap();
145                let order_str = cap_ballots.get(2).unwrap().as_str();
146                let order = order_str.split(' ').filter_map(|level| {
147                    if level.is_empty() {
148                        None
149                    } else {
150                        let mut level_candidates = level
151                            .split('=')
152                            .filter_map(|candidate| {
153                                if options.remove_withdrawn_candidates
154                                    && withdrawn.contains(candidate)
155                                {
156                                    None
157                                } else {
158                                    Some(*hash_nicknames.get(candidate).unwrap())
159                                }
160                            })
161                            .peekable();
162                        if level_candidates.peek().is_none() {
163                            None
164                        } else {
165                            Some(level_candidates)
166                        }
167                    }
168                });
169
170                let ballot = Ballot::new(count, order);
171                trace!(
172                    "Parsed ballot: count {count} for {:?}",
173                    ballot
174                        .order()
175                        .map(|rank| rank.iter().map(|&x| x.into()).collect::<Vec<_>>())
176                        .collect::<Vec<_>>()
177                );
178                if options.remove_empty_ballots && ballot.is_empty() {
179                    warn!("Removing ballot that is empty or contains only withdrawn candidates: {line}");
180                } else {
181                    ballot.validate();
182                    ballots.push(ballot);
183                }
184            }
185            None => {
186                warn!("Ignored line: {line:?}");
187            }
188        }
189    }
190
191    let num_ballots = ballots.iter().map(|b| b.count()).sum::<usize>();
192    info!("Number of ballots: {num_ballots}");
193
194    // This block intentionally clones the ballots into themselves to obtain a more
195    // efficient memory layout, which conflicts with this lint.
196    #[allow(clippy::assigning_clones)]
197    if options.optimize_layout {
198        ballots.sort_by(|a, b| {
199            let ita = a.order().map(|rank| rank.len());
200            let itb = b.order().map(|rank| rank.len());
201            ita.cmp(itb)
202        });
203        ballots = ballots.clone();
204    }
205
206    let candidates: Vec<Candidate> = nicknames
207        .into_iter()
208        .map(|nickname| {
209            let is_withdrawn = withdrawn.contains(&nickname);
210            Candidate {
211                name: remove_quotes(&lines.next().unwrap().unwrap()).to_string(),
212                nickname,
213                is_withdrawn,
214            }
215        })
216        .collect();
217
218    let title = remove_quotes(&lines.next().unwrap().unwrap()).to_string();
219    info!("Election title: {title}");
220
221    let election = Election {
222        title,
223        num_candidates,
224        num_seats,
225        num_ballots,
226        candidates,
227        ballots,
228        tie_order,
229    };
230    election.debug_allocations();
231    Ok(election)
232}
233
234/// Removes the leading and trailing quotes. The input string must start with a
235/// double-quote character and end with a double-quote character -- only these
236/// two characters are removed.
237fn remove_quotes(x: &str) -> &str {
238    // TODO: Implement a more robust parsing of quoted strings.
239    assert!(x.len() >= 2);
240    assert_eq!(*x.as_bytes().first().unwrap(), b'"');
241    assert_eq!(*x.as_bytes().last().unwrap(), b'"');
242    &x[1..x.len() - 1]
243}
244
245#[cfg(test)]
246mod test {
247    use super::*;
248    use crate::util::log_tester::ThreadLocalLogger;
249    use log::Level::{Debug, Info, Warn};
250    use log::LevelFilter;
251    use std::io::Cursor;
252
253    #[test]
254    fn test_remove_quotes() {
255        assert_eq!(remove_quotes("\"foo\""), "foo");
256        assert_eq!(remove_quotes("\"Hello world\""), "Hello world");
257    }
258
259    #[test]
260    #[should_panic(expected = "assertion failed: x.len() >= 2")]
261    fn test_remove_quotes_empty() {
262        remove_quotes("");
263    }
264
265    #[test]
266    #[should_panic(expected = "assertion failed: x.len() >= 2")]
267    fn test_remove_quotes_short() {
268        remove_quotes("\"");
269    }
270
271    #[test]
272    #[should_panic(expected = "assertion `left == right` failed\n  left: 102\n right: 34")]
273    fn test_remove_quotes_no_quotes() {
274        remove_quotes("foo");
275    }
276
277    #[test]
278    #[should_panic(expected = "assertion `left == right` failed\n  left: 225\n right: 34")]
279    fn test_remove_quotes_mid_utf8() {
280        remove_quotes("\u{1234}foo\"");
281    }
282
283    /// Returns baseline parsing options for tests where they don't matter.
284    fn basic_parsing_options() -> ParsingOptions {
285        ParsingOptions {
286            remove_withdrawn_candidates: true,
287            remove_empty_ballots: true,
288            optimize_layout: false,
289        }
290    }
291
292    #[test]
293    fn test_parse_election() {
294        let file = r#"5 2
295[nick apple banana cherry date eggplant]
296[tie cherry apple eggplant banana date]
2973 apple cherry eggplant date banana 0
2983 date=eggplant banana=cherry=apple 0
29942 cherry 0
300123 banana date 0
3010
302"Apple"
303"Banana"
304"Cherry"
305"Date"
306"Eggplant"
307"Vegetable contest"
308"#;
309        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
310        let election = parse_election(Cursor::new(file), basic_parsing_options()).unwrap();
311
312        assert_eq!(
313            election,
314            Election::builder()
315                .title("Vegetable contest")
316                .num_seats(2)
317                .candidates([
318                    Candidate::new("apple", false),
319                    Candidate::new("banana", false),
320                    Candidate::new("cherry", false),
321                    Candidate::new("date", false),
322                    Candidate::new("eggplant", false),
323                ])
324                .ballots(vec![
325                    Ballot::new(3, [vec![0], vec![2], vec![4], vec![3], vec![1]]),
326                    Ballot::new(3, [vec![3, 4], vec![1, 2, 0]]),
327                    Ballot::new(42, [vec![2]]),
328                    Ballot::new(123, [vec![1], vec![3]]),
329                ])
330                .check_num_ballots(171)
331                .tie_order([2, 0, 4, 1, 3])
332                .build()
333        );
334        logger.check_any_target_logs([
335            (Info, "Optimizing the in-memory layout of ballots is disabled"),
336            (Info, "2 seats / 5 candidates"),
337            (Info, "Nicknames: [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
338            (Info, "Tie-break order: [\"cherry\", \"apple\", \"eggplant\", \"banana\", \"date\"]"),
339            (Info, "Candidates (by nickname): [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
340            (Info, "Number of ballots: 171"),
341            (Info, "Election title: Vegetable contest"),
342            (Debug, "Allocations of 32 bytes: 8 => 256 bytes"),
343            (Debug, "Allocations of 192 bytes: 1 => 192 bytes"),
344            (Debug, "Ballots use 448 bytes in 9 allocations"),
345            (Debug, "Each ballot uses 112 bytes in 2.25 allocations"),
346        ]);
347    }
348
349    #[test]
350    fn test_parse_optimize_layout() {
351        let file = r#"5 2
352[nick apple banana cherry date eggplant]
353[tie cherry apple eggplant banana date]
3543 apple cherry eggplant date banana 0
3553 date=eggplant banana=cherry=apple 0
35642 cherry 0
357123 banana date 0
3580
359"Apple"
360"Banana"
361"Cherry"
362"Date"
363"Eggplant"
364"Vegetable contest"
365"#;
366        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
367        let election = parse_election(
368            Cursor::new(file),
369            ParsingOptions {
370                remove_withdrawn_candidates: true,
371                remove_empty_ballots: true,
372                optimize_layout: true,
373            },
374        )
375        .unwrap();
376
377        assert_eq!(
378            election,
379            Election::builder()
380                .title("Vegetable contest")
381                .num_seats(2)
382                .candidates([
383                    Candidate::new("apple", false),
384                    Candidate::new("banana", false),
385                    Candidate::new("cherry", false),
386                    Candidate::new("date", false),
387                    Candidate::new("eggplant", false),
388                ])
389                .ballots(vec![
390                    Ballot::new(42, [vec![2]]),
391                    Ballot::new(123, [vec![1], vec![3]]),
392                    Ballot::new(3, [vec![0], vec![2], vec![4], vec![3], vec![1]]),
393                    Ballot::new(3, [vec![3, 4], vec![1, 2, 0]]),
394                ])
395                .check_num_ballots(171)
396                .tie_order([2, 0, 4, 1, 3])
397                .build()
398        );
399        logger.check_any_target_logs([
400            (Info, "Optimizing the in-memory layout of ballots is enabled"),
401            (Info, "2 seats / 5 candidates"),
402            (Info, "Nicknames: [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
403            (Info, "Tie-break order: [\"cherry\", \"apple\", \"eggplant\", \"banana\", \"date\"]"),
404            (Info, "Candidates (by nickname): [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
405            (Info, "Number of ballots: 171"),
406            (Info, "Election title: Vegetable contest"),
407            (Debug, "Allocations of 32 bytes: 8 => 256 bytes"),
408            (Debug, "Allocations of 192 bytes: 1 => 192 bytes"),
409            (Debug, "Ballots use 448 bytes in 9 allocations"),
410            (Debug, "Each ballot uses 112 bytes in 2.25 allocations"),
411        ]);
412    }
413
414    #[test]
415    fn test_parse_names_with_digits() {
416        let file = r#"3 2
417[nick apple ba2nana34 cherry]
418[tie cherry apple ba2nana34]
4191 apple ba2nana34 0
4200
421"Apple"
422"Ba 2 nana 34"
423"Cherry"
424"Vegetable contest"
425"#;
426        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
427        let election = parse_election(Cursor::new(file), basic_parsing_options()).unwrap();
428
429        assert_eq!(
430            election,
431            Election::builder()
432                .title("Vegetable contest")
433                .num_seats(2)
434                .candidates([
435                    Candidate::new("apple", false),
436                    Candidate {
437                        nickname: "ba2nana34".to_owned(),
438                        name: "Ba 2 nana 34".to_owned(),
439                        is_withdrawn: false,
440                    },
441                    Candidate::new("cherry", false),
442                ])
443                .ballots(vec![Ballot::new(1, [vec![0], vec![1]])])
444                .check_num_ballots(1)
445                .tie_order([2, 0, 1])
446                .build()
447        );
448        logger.check_any_target_logs([
449            (
450                Info,
451                "Optimizing the in-memory layout of ballots is disabled",
452            ),
453            (Info, "2 seats / 3 candidates"),
454            (Info, "Nicknames: [\"apple\", \"ba2nana34\", \"cherry\"]"),
455            (
456                Info,
457                "Tie-break order: [\"cherry\", \"apple\", \"ba2nana34\"]",
458            ),
459            (
460                Info,
461                "Candidates (by nickname): [\"apple\", \"ba2nana34\", \"cherry\"]",
462            ),
463            (Info, "Number of ballots: 1"),
464            (Info, "Election title: Vegetable contest"),
465            (Debug, "Allocations of 32 bytes: 2 => 64 bytes"),
466            (Debug, "Allocations of 192 bytes: 1 => 192 bytes"),
467            (Debug, "Ballots use 256 bytes in 3 allocations"),
468            (Debug, "Each ballot uses 256 bytes in 3 allocations"),
469        ]);
470    }
471
472    #[test]
473    fn test_parse_withdrawn_keep_all() {
474        let file = r#"5 2
475[nick apple banana cherry date eggplant]
476[withdrawn cherry eggplant]
4773 apple cherry eggplant date banana 0
4783 date=eggplant banana=cherry=apple 0
47942 cherry 0
480123 banana date 0
48117 0
4820
483"Apple"
484"Banana"
485"Cherry"
486"Date"
487"Eggplant"
488"Vegetable contest"
489"#;
490        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
491        let election = parse_election(
492            Cursor::new(file),
493            ParsingOptions {
494                remove_withdrawn_candidates: false,
495                remove_empty_ballots: false,
496                optimize_layout: false,
497            },
498        )
499        .unwrap();
500
501        assert_eq!(
502            election,
503            Election::builder()
504                .title("Vegetable contest")
505                .num_seats(2)
506                .candidates([
507                    Candidate::new("apple", false),
508                    Candidate::new("banana", false),
509                    Candidate::new("cherry", true),
510                    Candidate::new("date", false),
511                    Candidate::new("eggplant", true),
512                ])
513                .ballots(vec![
514                    Ballot::new(3, [vec![0], vec![2], vec![4], vec![3], vec![1]]),
515                    Ballot::new(3, [vec![3, 4], vec![1, 2, 0]]),
516                    Ballot::new(42, [vec![2]]),
517                    Ballot::new(123, [vec![1], vec![3]]),
518                    Ballot::empties(17),
519                ])
520                .check_num_ballots(188)
521                .build()
522        );
523        logger.check_any_target_logs([
524            (Info, "Optimizing the in-memory layout of ballots is disabled"),
525            (Info, "2 seats / 5 candidates"),
526            (Info, "Nicknames: [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
527            (Info, "Withdrawn: [\"cherry\", \"eggplant\"]"),
528            (Info, "Candidates (by nickname): [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
529            (Info, "Number of ballots: 188"),
530            (Info, "Election title: Vegetable contest"),
531            (Debug, "Allocations of 0 bytes: 2 => 0 bytes"),
532            (Debug, "Allocations of 32 bytes: 8 => 256 bytes"),
533            (Debug, "Allocations of 384 bytes: 1 => 384 bytes"),
534            (Debug, "Ballots use 640 bytes in 11 allocations"),
535            (Debug, "Each ballot uses 128 bytes in 2.2 allocations"),
536        ]);
537    }
538
539    #[test]
540    fn test_parse_withdrawn_remove_withdrawn() {
541        let file = r#"5 2
542[nick apple banana cherry date eggplant]
543[withdrawn cherry eggplant]
5443 apple cherry eggplant date banana 0
5453 date=eggplant banana=cherry=apple 0
54642 cherry 0
547123 banana date 0
54817 0
5490
550"Apple"
551"Banana"
552"Cherry"
553"Date"
554"Eggplant"
555"Vegetable contest"
556"#;
557        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
558        let election = parse_election(
559            Cursor::new(file),
560            ParsingOptions {
561                remove_withdrawn_candidates: true,
562                remove_empty_ballots: false,
563                optimize_layout: false,
564            },
565        )
566        .unwrap();
567
568        assert_eq!(
569            election,
570            Election::builder()
571                .title("Vegetable contest")
572                .num_seats(2)
573                .candidates([
574                    Candidate::new("apple", false),
575                    Candidate::new("banana", false),
576                    Candidate::new("cherry", true),
577                    Candidate::new("date", false),
578                    Candidate::new("eggplant", true),
579                ])
580                .ballots(vec![
581                    Ballot::new(3, [vec![0], vec![3], vec![1]]),
582                    Ballot::new(3, [vec![3], vec![1, 0]]),
583                    Ballot::empties(42),
584                    Ballot::new(123, [vec![1], vec![3]]),
585                    Ballot::empties(17),
586                ])
587                .check_num_ballots(188)
588                .build()
589        );
590        logger.check_any_target_logs([
591            (Info, "Optimizing the in-memory layout of ballots is disabled"),
592            (Info, "2 seats / 5 candidates"),
593            (Info, "Nicknames: [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
594            (Info, "Withdrawn: [\"cherry\", \"eggplant\"]"),
595            (Info, "Candidates (by nickname): [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
596            (Info, "Number of ballots: 188"),
597            (Info, "Election title: Vegetable contest"),
598            (Debug, "Allocations of 0 bytes: 4 => 0 bytes"),
599            (Debug, "Allocations of 32 bytes: 6 => 192 bytes"),
600            (Debug, "Allocations of 384 bytes: 1 => 384 bytes"),
601            (Debug, "Ballots use 576 bytes in 11 allocations"),
602            (Debug, "Each ballot uses 115.2 bytes in 2.2 allocations"),
603        ]);
604    }
605
606    #[test]
607    fn test_parse_withdrawn_remove_empty_ballots() {
608        let file = r#"5 2
609[nick apple banana cherry date eggplant]
610[withdrawn cherry eggplant]
6113 apple cherry eggplant date banana 0
6123 date=eggplant banana=cherry=apple 0
61342 cherry 0
614123 banana date 0
61517 0
6160
617"Apple"
618"Banana"
619"Cherry"
620"Date"
621"Eggplant"
622"Vegetable contest"
623"#;
624        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
625        let election = parse_election(
626            Cursor::new(file),
627            ParsingOptions {
628                remove_withdrawn_candidates: false,
629                remove_empty_ballots: true,
630                optimize_layout: false,
631            },
632        )
633        .unwrap();
634
635        assert_eq!(
636            election,
637            Election::builder()
638                .title("Vegetable contest")
639                .num_seats(2)
640                .candidates([
641                    Candidate::new("apple", false),
642                    Candidate::new("banana", false),
643                    Candidate::new("cherry", true),
644                    Candidate::new("date", false),
645                    Candidate::new("eggplant", true),
646                ])
647                .ballots(vec![
648                    Ballot::new(3, [vec![0], vec![2], vec![4], vec![3], vec![1]]),
649                    Ballot::new(3, [vec![3, 4], vec![1, 2, 0]]),
650                    Ballot::new(42, [vec![2]]),
651                    Ballot::new(123, [vec![1], vec![3]]),
652                ])
653                .check_num_ballots(171)
654                .build()
655        );
656        logger.check_any_target_logs([
657            (Info, "Optimizing the in-memory layout of ballots is disabled"),
658            (Info, "2 seats / 5 candidates"),
659            (Info, "Nicknames: [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
660            (Info, "Withdrawn: [\"cherry\", \"eggplant\"]"),
661            (Info, "Candidates (by nickname): [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
662            (Warn, "Removing ballot that is empty or contains only withdrawn candidates: 17 0"),
663            (Info, "Number of ballots: 171"),
664            (Info, "Election title: Vegetable contest"),
665            (Debug, "Allocations of 32 bytes: 8 => 256 bytes"),
666            (Debug, "Allocations of 192 bytes: 1 => 192 bytes"),
667            (Debug, "Ballots use 448 bytes in 9 allocations"),
668            (Debug, "Each ballot uses 112 bytes in 2.25 allocations"),
669        ]);
670    }
671
672    #[test]
673    fn test_parse_withdrawn_remove_all() {
674        let file = r#"5 2
675[nick apple banana cherry date eggplant]
676[withdrawn cherry eggplant]
6773 apple cherry eggplant date banana 0
6783 date=eggplant banana=cherry=apple 0
67942 cherry 0
680123 banana date 0
68117 0
6820
683"Apple"
684"Banana"
685"Cherry"
686"Date"
687"Eggplant"
688"Vegetable contest"
689"#;
690        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
691        let election = parse_election(
692            Cursor::new(file),
693            ParsingOptions {
694                remove_withdrawn_candidates: true,
695                remove_empty_ballots: true,
696                optimize_layout: false,
697            },
698        )
699        .unwrap();
700
701        assert_eq!(
702            election,
703            Election::builder()
704                .title("Vegetable contest")
705                .num_seats(2)
706                .candidates([
707                    Candidate::new("apple", false),
708                    Candidate::new("banana", false),
709                    Candidate::new("cherry", true),
710                    Candidate::new("date", false),
711                    Candidate::new("eggplant", true),
712                ])
713                .ballots(vec![
714                    Ballot::new(3, [vec![0], vec![3], vec![1]]),
715                    Ballot::new(3, [vec![3], vec![1, 0]]),
716                    Ballot::new(123, [vec![1], vec![3]]),
717                ])
718                .check_num_ballots(129)
719                .build()
720        );
721        logger.check_any_target_logs([
722            (Info, "Optimizing the in-memory layout of ballots is disabled"),
723            (Info, "2 seats / 5 candidates"),
724            (Info, "Nicknames: [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
725            (Info, "Withdrawn: [\"cherry\", \"eggplant\"]"),
726            (Info, "Candidates (by nickname): [\"apple\", \"banana\", \"cherry\", \"date\", \"eggplant\"]"),
727            (Warn, "Removing ballot that is empty or contains only withdrawn candidates: 42 cherry 0"),
728            (Warn, "Removing ballot that is empty or contains only withdrawn candidates: 17 0"),
729            (Info, "Number of ballots: 129"),
730            (Info, "Election title: Vegetable contest"),
731            (Debug, "Allocations of 32 bytes: 6 => 192 bytes"),
732            (Debug, "Allocations of 192 bytes: 1 => 192 bytes"),
733            (Debug, "Ballots use 384 bytes in 7 allocations"),
734            (Debug, "Each ballot uses 128 bytes in 2.3333333333333335 allocations"),
735        ]);
736    }
737
738    #[test]
739    fn test_parse_unknown_option() {
740        let file = r#"2 1
741[nick apple banana]
742[unknown foo bar]
7431 apple 0
7440
745"Apple"
746"Banana"
747"Vegetable contest"
748"#;
749        let logger = ThreadLocalLogger::start_filtered(LevelFilter::Debug);
750        let election = parse_election(Cursor::new(file), basic_parsing_options()).unwrap();
751
752        assert_eq!(
753            election,
754            Election::builder()
755                .title("Vegetable contest")
756                .num_seats(1)
757                .candidates([
758                    Candidate::new("apple", false),
759                    Candidate::new("banana", false),
760                ])
761                .ballots(vec![Ballot::new(1, [vec![0]])])
762                .check_num_ballots(1)
763                .build()
764        );
765        logger.check_any_target_logs([
766            (
767                Info,
768                "Optimizing the in-memory layout of ballots is disabled",
769            ),
770            (Info, "1 seats / 2 candidates"),
771            (Info, "Nicknames: [\"apple\", \"banana\"]"),
772            (Warn, "Unknown option: unknown"),
773            (Info, "Candidates (by nickname): [\"apple\", \"banana\"]"),
774            (Info, "Number of ballots: 1"),
775            (Info, "Election title: Vegetable contest"),
776            (Debug, "Allocations of 32 bytes: 2 => 64 bytes"),
777            (Debug, "Allocations of 192 bytes: 1 => 192 bytes"),
778            (Debug, "Ballots use 256 bytes in 3 allocations"),
779            (Debug, "Each ballot uses 256 bytes in 3 allocations"),
780        ]);
781    }
782
783    #[test]
784    #[should_panic(
785        expected = "assertion `left == right` failed: Tie-break order must mention all candidates\n  left: 1\n right: 2"
786    )]
787    fn test_parse_tie_not_all_candidates() {
788        let file = r#"2 1
789[nick apple banana]
790[tie banana]
7911 apple 0
7920
793"Apple"
794"Banana"
795"Vegetable contest"
796"#;
797        let _ = parse_election(Cursor::new(file), basic_parsing_options());
798    }
799
800    #[test]
801    #[should_panic(expected = "Candidate mentioned twice in tie order: banana")]
802    fn test_parse_tie_repeated_candidate() {
803        let file = r#"2 1
804[nick apple banana]
805[tie banana banana]
8061 apple 0
8070
808"Apple"
809"Banana"
810"Vegetable contest"
811"#;
812        let _ = parse_election(Cursor::new(file), basic_parsing_options());
813    }
814
815    #[test]
816    #[should_panic(expected = "assertion `left == right` failed\n  left: 2\n right: 1")]
817    fn test_parse_ballot_repeated_candidate() {
818        let file = r#"2 1
819[nick apple banana]
8201 apple apple 0
8210
822"Apple"
823"Banana"
824"Vegetable contest"
825"#;
826        let _ = parse_election(Cursor::new(file), basic_parsing_options());
827    }
828
829    #[test]
830    #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
831    fn test_parse_ballot_unknown_nickname() {
832        let file = r#"2 1
833[nick apple banana]
8341 appppppple 0
8350
836"appppppple"
837"bananaaaaa"
838"Vegetable contest"
839"#;
840        let _ = parse_election(Cursor::new(file), basic_parsing_options());
841    }
842}