|
1 |
+ |
//! A starter pack of filename rules for Layer A.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Where instrument identity is genuinely wanted, the filename beats the audio by
|
|
4 |
+ |
//! a wide margin. Our own ground truth proves it: `scripts/corpus.py` labelled
|
|
5 |
+ |
//! 1,049 of 1,074 one-shots from filenames alone, 97.7%, against the retired DSP
|
|
6 |
+ |
//! classifier's 33.4% on those exact files. Real packs name their files `Kick.wav`.
|
|
7 |
+ |
//!
|
|
8 |
+ |
//! So this is the vocabulary from that script, carried into the rules engine
|
|
9 |
+ |
//! (`crate::rules`, Layer A) as ordinary rules: ordered, deterministic,
|
|
10 |
+ |
//! re-runnable, provenance-tracked as `source = 'rule'`, and fully editable once
|
|
11 |
+ |
//! seeded. Nothing here is privileged over a rule the user writes.
|
|
12 |
+ |
//!
|
|
13 |
+ |
//! Seeded rules arrive **disabled**, matching how imported `.afcl` rules arrive, so
|
|
14 |
+ |
//! the pack is a starting point offered for review rather than a library-wide
|
|
15 |
+ |
//! retag the user did not ask for.
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! ## Family-level tags, via the hierarchy
|
|
18 |
+ |
//!
|
|
19 |
+ |
//! Across 9,493 Freesound sounds, 60.5% carry an instrument-family word and only
|
|
20 |
+ |
//! 13.5% a specific instrument; of sounds tagged with a drum family word, just 21%
|
|
21 |
+ |
//! name the specific drum. The tags here are specific where the filename is
|
|
22 |
+ |
//! (`instrument.drum.kick`), which serves family queries anyway because tag search
|
|
23 |
+ |
//! is prefix-based: filtering on `instrument.drum` matches every drum below it. A
|
|
24 |
+ |
//! separate bare family tag would say the same thing twice.
|
|
25 |
+ |
//!
|
|
26 |
+ |
//! ## Ambiguity: a name that hits two classes gets neither
|
|
27 |
+ |
//!
|
|
28 |
+ |
//! `corpus.py` drops a file matching two classes rather than resolving by rule
|
|
29 |
+ |
//! order, because these packs contain genuinely layered hits (`Kick_Cowbell.wav`,
|
|
30 |
+ |
//! `Tom-Cymbal.wav`) and first-match-wins would pick one at random. That behaviour
|
|
31 |
+ |
//! carries over here, since a wrong tag is worse than no tag.
|
|
32 |
+ |
//!
|
|
33 |
+ |
//! Expressing it takes two passes, because [`MatchMode`] is per-rule: there is no
|
|
34 |
+ |
//! way to write "any of these keywords AND none of those" as one rule. So each
|
|
35 |
+ |
//! drum class gets a keyword rule that adds its tag, and then a guard rule that
|
|
36 |
+ |
//! removes that same tag when any OTHER drum class's keyword is also present.
|
|
37 |
+ |
//! `RuleAction::RemoveTag` suppresses an earlier add in the same pass, so the guards
|
|
38 |
+ |
//! run after the adds (higher priority number) and the layered hit ends up with
|
|
39 |
+ |
//! neither tag. On an unambiguous name the guard simply does not match.
|
|
40 |
+ |
//!
|
|
41 |
+ |
//! Only the drum classes guard each other. "Bass Guitar Loop" being tagged both
|
|
42 |
+ |
//! `instrument.bass` and `instrument.guitar` is correct, not ambiguous.
|
|
43 |
+ |
|
|
44 |
+ |
use crate::db::Database;
|
|
45 |
+ |
use crate::error::Result;
|
|
46 |
+ |
use crate::rules::{MatchMode, NewRule, RuleAction, RuleCondition, RuleField, RuleOp};
|
|
47 |
+ |
|
|
48 |
+ |
/// One class in the starter vocabulary: the tag it applies and the filename
|
|
49 |
+ |
/// keywords that trigger it.
|
|
50 |
+ |
pub struct StarterClass {
|
|
51 |
+ |
/// Short label, used to build the rule name.
|
|
52 |
+ |
pub label: &'static str,
|
|
53 |
+ |
/// The tag applied on a match.
|
|
54 |
+ |
pub tag: &'static str,
|
|
55 |
+ |
/// Case-insensitive substrings tested against the filename. Any one matches.
|
|
56 |
+ |
pub keywords: &'static [&'static str],
|
|
57 |
+ |
}
|
|
58 |
+ |
|
|
59 |
+ |
/// The drum classes, which guard each other against layered-hit names.
|
|
60 |
+ |
///
|
|
61 |
+ |
/// Keywords are `corpus.py`'s `CLASS_RULES` minus its two-letter abbreviations
|
|
62 |
+ |
/// (` bd`, `_sd`, ` hh`). Those were tuned for one specific set of drum-machine
|
|
63 |
+ |
/// packs, where the surrounding whitespace made them safe; against an arbitrary
|
|
64 |
+ |
/// library they are false-positive bait, and this pack has to work on a library it
|
|
65 |
+ |
/// has never seen.
|
|
66 |
+ |
pub const DRUM_CLASSES: &[StarterClass] = &[
|
|
67 |
+ |
StarterClass {
|
|
68 |
+ |
label: "kick",
|
|
69 |
+ |
tag: "instrument.drum.kick",
|
|
70 |
+ |
keywords: &["kick", "bassdrum", "bass drum", "kik"],
|
|
71 |
+ |
},
|
|
72 |
+ |
StarterClass {
|
|
73 |
+ |
label: "snare",
|
|
74 |
+ |
tag: "instrument.drum.snare",
|
|
75 |
+ |
keywords: &["snare", "rimshot", "rim shot", "sidestick", "side stick"],
|
|
76 |
+ |
},
|
|
77 |
+ |
StarterClass {
|
|
78 |
+ |
label: "hi-hat",
|
|
79 |
+ |
tag: "instrument.drum.hihat",
|
|
80 |
+ |
keywords: &["hihat", "hi hat", "hi-hat", "hat"],
|
|
81 |
+ |
},
|
|
82 |
+ |
StarterClass {
|
|
83 |
+ |
label: "cymbal",
|
|
84 |
+ |
tag: "instrument.drum.cymbal",
|
|
85 |
+ |
keywords: &["cymbal", "crash", "ride", "splash", "china", "gong"],
|
|
86 |
+ |
},
|
|
87 |
+ |
StarterClass {
|
|
88 |
+ |
label: "clap",
|
|
89 |
+ |
tag: "instrument.drum.clap",
|
|
90 |
+ |
keywords: &["clap", "handclap"],
|
|
91 |
+ |
},
|
|
92 |
+ |
StarterClass {
|
|
93 |
+ |
label: "tom",
|
|
94 |
+ |
tag: "instrument.drum.tom",
|
|
95 |
+ |
keywords: &["tom"],
|
|
96 |
+ |
},
|
|
97 |
+ |
StarterClass {
|
|
98 |
+ |
label: "percussion",
|
|
99 |
+ |
tag: "instrument.percussion",
|
|
100 |
+ |
keywords: &[
|
|
101 |
+ |
"perc",
|
|
102 |
+ |
"cowbell",
|
|
103 |
+ |
"clave",
|
|
104 |
+ |
"maraca",
|
|
105 |
+ |
"bongo",
|
|
106 |
+ |
"conga",
|
|
107 |
+ |
"guiro",
|
|
108 |
+ |
"shaker",
|
|
109 |
+ |
"tambourine",
|
|
110 |
+ |
"triangle",
|
|
111 |
+ |
"agogo",
|
|
112 |
+ |
"cabasa",
|
|
113 |
+ |
"timbale",
|
|
114 |
+ |
"woodblock",
|
|
115 |
+ |
"chime",
|
|
116 |
+ |
],
|
|
117 |
+ |
},
|
|
118 |
+ |
];
|
|
119 |
+ |
|
|
120 |
+ |
/// Everything else: instrument families and format words, no cross-guarding.
|
|
121 |
+ |
///
|
|
122 |
+ |
/// Format first on purpose. Format and tempo are the most-used tag kind in the
|
|
123 |
+ |
/// FSL10K survey at 91.8%, ahead of instrument family at 60.5%, and "is this a
|
|
124 |
+ |
/// loop or a one-shot" is the cheapest true thing a filename says.
|
|
125 |
+ |
pub const OTHER_CLASSES: &[StarterClass] = &[
|
|
126 |
+ |
StarterClass {
|
|
127 |
+ |
label: "loop",
|
|
128 |
+ |
tag: "type.loop",
|
|
129 |
+ |
keywords: &["loop"],
|
|
130 |
+ |
},
|
|
131 |
+ |
StarterClass {
|
|
132 |
+ |
label: "one-shot",
|
|
133 |
+ |
tag: "type.one-shot",
|
|
134 |
+ |
keywords: &["oneshot", "one-shot", "one shot"],
|
|
135 |
+ |
},
|
|
136 |
+ |
StarterClass {
|
|
137 |
+ |
label: "bass",
|
|
138 |
+ |
tag: "instrument.bass",
|
|
139 |
+ |
keywords: &["bass", "sub bass", "808"],
|
|
140 |
+ |
},
|
|
141 |
+ |
StarterClass {
|
|
142 |
+ |
label: "vocal",
|
|
143 |
+ |
tag: "instrument.vocal",
|
|
144 |
+ |
keywords: &["vocal", "vox", "acapella", "a capella"],
|
|
145 |
+ |
},
|
|
146 |
+ |
StarterClass {
|
|
147 |
+ |
label: "synth",
|
|
148 |
+ |
tag: "instrument.synth",
|
|
149 |
+ |
keywords: &["synth", "arp"],
|
|
150 |
+ |
},
|
|
151 |
+ |
StarterClass {
|
|
152 |
+ |
label: "pad",
|
|
153 |
+ |
tag: "instrument.pad",
|
|
154 |
+ |
keywords: &["pad"],
|
|
155 |
+ |
},
|
|
156 |
+ |
StarterClass {
|
|
157 |
+ |
label: "guitar",
|
|
158 |
+ |
tag: "instrument.guitar",
|
|
159 |
+ |
keywords: &["guitar", "gtr"],
|
|
160 |
+ |
},
|
|
161 |
+ |
StarterClass {
|
|
162 |
+ |
label: "piano",
|
|
163 |
+ |
tag: "instrument.piano",
|
|
164 |
+ |
keywords: &["piano", "rhodes"],
|
|
165 |
+ |
},
|
|
166 |
+ |
StarterClass {
|
|
167 |
+ |
label: "strings",
|
|
168 |
+ |
tag: "instrument.strings",
|
|
169 |
+ |
keywords: &["strings", "violin", "cello", "viola"],
|
|
170 |
+ |
},
|
|
171 |
+ |
StarterClass {
|
|
172 |
+ |
label: "brass",
|
|
173 |
+ |
tag: "instrument.brass",
|
|
174 |
+ |
keywords: &["brass", "trumpet", "trombone", " sax", "saxophone"],
|
|
175 |
+ |
},
|
|
176 |
+ |
StarterClass {
|
|
177 |
+ |
label: "fx",
|
|
178 |
+ |
tag: "character.fx",
|
|
179 |
+ |
keywords: &["riser", "downlifter", "uplifter", "whoosh", "sweep"],
|
|
180 |
+ |
},
|
|
181 |
+ |
];
|
|
182 |
+ |
|
|
183 |
+ |
/// `instrument.bass` needs the same suppression the drum classes give each other,
|
|
184 |
+ |
/// for one specific reason: "bassdrum" and "bass drum" contain "bass". Without this
|
|
185 |
+ |
/// a kick named `BassDrum.wav` would carry `instrument.bass`, which is not a
|
|
186 |
+ |
/// layered hit, just a substring accident.
|
|
187 |
+ |
const BASS_GUARD_KEYWORDS: &[&str] = &["bassdrum", "bass drum", "kick"];
|
|
188 |
+ |
|
|
189 |
+ |
/// Name a seeded rule. Prefixed so the pack is identifiable in the rules list, and
|
|
190 |
+ |
/// so [`seed`] can tell an already-seeded database from a fresh one.
|
|
191 |
+ |
fn rule_name(label: &str) -> String {
|
|
192 |
+ |
format!("Filename: {label}")
|
|
193 |
+ |
}
|
|
194 |
+ |
|
|
195 |
+ |
fn guard_name(label: &str) -> String {
|
|
196 |
+ |
format!("Filename: {label} (skip if ambiguous)")
|
|
197 |
+ |
}
|
|
198 |
+ |
|
|
199 |
+ |
fn contains(keyword: &str) -> RuleCondition {
|
|
200 |
+ |
RuleCondition {
|
|
201 |
+ |
field: RuleField::Name,
|
|
202 |
+ |
op: RuleOp::Contains,
|
|
203 |
+ |
value: keyword.to_string(),
|
|
204 |
+ |
}
|
|
205 |
+ |
}
|
|
206 |
+ |
|
|
207 |
+ |
/// The rules this pack would create, in evaluation order: every keyword rule
|
|
208 |
+ |
/// first, then the guards that suppress an ambiguous match.
|
|
209 |
+ |
///
|
|
210 |
+ |
/// Split out from [`seed`] so the shape is testable without a database.
|
|
211 |
+ |
pub fn rules() -> Vec<NewRule> {
|
|
212 |
+ |
let mut out = Vec::new();
|
|
213 |
+ |
|
|
214 |
+ |
for class in DRUM_CLASSES.iter().chain(OTHER_CLASSES) {
|
|
215 |
+ |
out.push(NewRule {
|
|
216 |
+ |
name: rule_name(class.label),
|
|
217 |
+ |
enabled: false,
|
|
218 |
+ |
priority: None,
|
|
219 |
+ |
match_mode: MatchMode::Any,
|
|
220 |
+ |
conditions: class.keywords.iter().copied().map(contains).collect(),
|
|
221 |
+ |
actions: vec![RuleAction::AddTag(class.tag.to_string())],
|
|
222 |
+ |
});
|
|
223 |
+ |
}
|
|
224 |
+ |
|
|
225 |
+ |
// Guards run after every add, so they get later priorities by construction
|
|
226 |
+ |
// (priority: None appends). A guard fires when a name carries some OTHER drum
|
|
227 |
+ |
// class's keyword, and drops its own class's tag.
|
|
228 |
+ |
for class in DRUM_CLASSES {
|
|
229 |
+ |
let others: Vec<RuleCondition> = DRUM_CLASSES
|
|
230 |
+ |
.iter()
|
|
231 |
+ |
.filter(|c| c.tag != class.tag)
|
|
232 |
+ |
.flat_map(|c| c.keywords.iter().copied())
|
|
233 |
+ |
.map(contains)
|
|
234 |
+ |
.collect();
|
|
235 |
+ |
out.push(NewRule {
|
|
236 |
+ |
name: guard_name(class.label),
|
|
237 |
+ |
enabled: false,
|
|
238 |
+ |
priority: None,
|
|
239 |
+ |
match_mode: MatchMode::Any,
|
|
240 |
+ |
conditions: others,
|
|
241 |
+ |
actions: vec![RuleAction::RemoveTag(class.tag.to_string())],
|
|
242 |
+ |
});
|
|
243 |
+ |
}
|
|
244 |
+ |
|
|
245 |
+ |
out.push(NewRule {
|
|
246 |
+ |
name: guard_name("bass"),
|
|
247 |
+ |
enabled: false,
|
|
248 |
+ |
priority: None,
|
|
249 |
+ |
match_mode: MatchMode::Any,
|
|
250 |
+ |
conditions: BASS_GUARD_KEYWORDS.iter().copied().map(contains).collect(),
|
|
251 |
+ |
actions: vec![RuleAction::RemoveTag("instrument.bass".to_string())],
|
|
252 |
+ |
});
|
|
253 |
+ |
|
|
254 |
+ |
out
|
|
255 |
+ |
}
|
|
256 |
+ |
|
|
257 |
+ |
/// Insert the starter pack, skipping any rule whose name is already present.
|
|
258 |
+ |
///
|
|
259 |
+ |
/// Returns the number of rules created, so a second call reports 0 rather than
|
|
260 |
+ |
/// duplicating the pack. Rules arrive disabled; nothing is tagged until the user
|
|
261 |
+ |
/// enables them and applies.
|
|
262 |
+ |
pub fn seed(db: &Database) -> Result<usize> {
|
|
263 |
+ |
let existing: Vec<String> = crate::rules::list_rules(db)?
|
|
264 |
+ |
.into_iter()
|
|
265 |
+ |
.map(|r| r.name)
|
|
266 |
+ |
.collect();
|
|
267 |
+ |
|
|
268 |
+ |
let mut created = 0;
|
|
269 |
+ |
for rule in rules() {
|
|
270 |
+ |
if existing.contains(&rule.name) {
|
|
271 |
+ |
continue;
|
|
272 |
+ |
}
|
|
273 |
+ |
crate::rules::create_rule(db, rule)?;
|
|
274 |
+ |
created += 1;
|
|
275 |
+ |
}
|
|
276 |
+ |
Ok(created)
|
|
277 |
+ |
}
|
|
278 |
+ |
|
|
279 |
+ |
#[cfg(test)]
|
|
280 |
+ |
mod tests {
|
|
281 |
+ |
use super::*;
|
|
282 |
+ |
use crate::rules::{self};
|
|
283 |
+ |
|
|
284 |
+ |
/// Seed the pack, enable every rule, then run the real engine over a sample
|
|
285 |
+ |
/// named `name` and return the tags it ended up with.
|
|
286 |
+ |
///
|
|
287 |
+ |
/// Deliberately not a reimplementation of `evaluate()`: the guards depend on
|
|
288 |
+ |
/// RemoveTag suppressing an earlier add within one pass, which is the engine's
|
|
289 |
+ |
/// behaviour to define, not this test's to restate.
|
|
290 |
+ |
fn tags_for(name: &str) -> Vec<String> {
|
|
291 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
292 |
+ |
seed(&db).unwrap();
|
|
293 |
+ |
for rule in rules::list_rules(&db).unwrap() {
|
|
294 |
+ |
rules::set_rule_enabled(&db, &rule.id, true).unwrap();
|
|
295 |
+ |
}
|
|
296 |
+ |
|
|
297 |
+ |
let hash = "a".repeat(64);
|
|
298 |
+ |
let now = crate::error::unix_now();
|
|
299 |
+ |
db.conn()
|
|
300 |
+ |
.execute(
|
|
301 |
+ |
"INSERT INTO samples (hash, original_name, file_extension, file_size,
|
|
302 |
+ |
import_date, last_modified)
|
|
303 |
+ |
VALUES (?1, ?2, 'wav', 100, ?3, ?3)",
|
|
304 |
+ |
rusqlite::params![hash, name, now],
|
|
305 |
+ |
)
|
|
306 |
+ |
.unwrap();
|
|
307 |
+ |
|
|
308 |
+ |
rules::apply_rules_to_sample(&db, &hash).unwrap();
|
|
309 |
+ |
let mut tags = crate::tags::get_sample_tags(&db, &hash).unwrap();
|
|
310 |
+ |
tags.sort();
|
|
311 |
+ |
tags
|
|
312 |
+ |
}
|
|
313 |
+ |
|
|
314 |
+ |
#[test]
|
|
315 |
+ |
fn every_tag_in_the_pack_is_valid() {
|
|
316 |
+ |
// create_rule validates tags and would error at seed time; catching it here
|
|
317 |
+ |
// names the offending tag instead.
|
|
318 |
+ |
for class in DRUM_CLASSES.iter().chain(OTHER_CLASSES) {
|
|
319 |
+ |
crate::tags::validate_tag(class.tag)
|
|
320 |
+ |
.unwrap_or_else(|e| panic!("{} is not a valid tag: {e}", class.tag));
|
|
321 |
+ |
}
|
|
322 |
+ |
}
|
|
323 |
+ |
|
|
324 |
+ |
#[test]
|
|
325 |
+ |
fn unambiguous_names_get_their_tag() {
|
|
326 |
+ |
assert_eq!(tags_for("Kick 01.wav"), ["instrument.drum.kick"]);
|
|
327 |
+ |
assert_eq!(tags_for("BD_Snare_Hard.aiff"), ["instrument.drum.snare"]);
|
|
328 |
+ |
assert_eq!(tags_for("Closed Hat.wav"), ["instrument.drum.hihat"]);
|
|
329 |
+ |
assert_eq!(tags_for("Crash Cymbal.wav"), ["instrument.drum.cymbal"]);
|
|
330 |
+ |
assert_eq!(tags_for("Cowbell.wav"), ["instrument.percussion"]);
|
|
331 |
+ |
}
|
|
332 |
+ |
|
|
333 |
+ |
#[test]
|
|
334 |
+ |
fn layered_hit_names_get_neither_tag() {
|
|
335 |
+ |
// The corpus.py behaviour: two classes in one name means the name cannot be
|
|
336 |
+ |
// trusted for either, so both are dropped rather than one picked by order.
|
|
337 |
+ |
assert!(tags_for("Kick_Cowbell.wav").is_empty());
|
|
338 |
+ |
assert!(tags_for("Tom-Cymbal.wav").is_empty());
|
|
339 |
+ |
assert!(tags_for("Kick-Hat 3.wav").is_empty());
|
|
340 |
+ |
assert!(tags_for("Snare+Crash.wav").is_empty());
|
|
341 |
+ |
}
|
|
342 |
+ |
|
|
343 |
+ |
#[test]
|
|
344 |
+ |
fn bass_drum_is_a_kick_and_not_a_bass() {
|
|
345 |
+ |
// "bassdrum" contains "bass"; the bass guard is what keeps a kick out of
|
|
346 |
+ |
// the bass family.
|
|
347 |
+ |
assert_eq!(tags_for("BassDrum 2.wav"), ["instrument.drum.kick"]);
|
|
348 |
+ |
assert_eq!(tags_for("Bass Drum Long.wav"), ["instrument.drum.kick"]);
|
|
349 |
+ |
// A real bass still lands.
|
|
350 |
+ |
assert_eq!(tags_for("Reese Bass F.wav"), ["instrument.bass"]);
|
|
351 |
+ |
}
|
|
352 |
+ |
|
|
353 |
+ |
#[test]
|
|
354 |
+ |
fn format_words_stack_with_instrument_families() {
|
|
355 |
+ |
// Format and instrument are orthogonal, so unlike the drum classes these
|
|
356 |
+ |
// are meant to co-occur.
|
|
357 |
+ |
let tags = tags_for("Guitar Loop 120.wav");
|
|
358 |
+ |
assert!(tags.contains(&"type.loop".to_string()));
|
|
359 |
+ |
assert!(tags.contains(&"instrument.guitar".to_string()));
|
|
360 |
+ |
|
|
361 |
+ |
let tags = tags_for("Bass Guitar Oneshot.wav");
|
|
362 |
+ |
assert!(tags.contains(&"instrument.bass".to_string()));
|
|
363 |
+ |
assert!(tags.contains(&"instrument.guitar".to_string()));
|
|
364 |
+ |
assert!(tags.contains(&"type.one-shot".to_string()));
|
|
365 |
+ |
}
|
|
366 |
+ |
|
|
367 |
+ |
#[test]
|
|
368 |
+ |
fn a_name_with_nothing_recognisable_gets_nothing() {
|
|
369 |
+ |
assert!(tags_for("Untitled-3.wav").is_empty());
|
|
370 |
+ |
assert!(tags_for("MJ_92_04.wav").is_empty());
|
|
371 |
+ |
}
|
|
372 |
+ |
|
|
373 |
+ |
#[test]
|
|
374 |
+ |
fn seeding_is_idempotent() {
|
|
375 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
376 |
+ |
let first = seed(&db).unwrap();
|
|
377 |
+ |
assert_eq!(first, rules().len());
|
|
378 |
+ |
assert_eq!(seed(&db).unwrap(), 0, "second seed must create nothing");
|
|
379 |
+ |
assert_eq!(rules::list_rules(&db).unwrap().len(), first);
|
|
380 |
+ |
}
|
|
381 |
+ |
|
|
382 |
+ |
#[test]
|
|
383 |
+ |
fn seeded_rules_arrive_disabled() {
|
|
384 |
+ |
// The pack is an offer, not a retag. Enabling is the user's call, and until
|
|
385 |
+ |
// then applying rules changes nothing.
|
|
386 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
387 |
+ |
seed(&db).unwrap();
|
|
388 |
+ |
assert!(rules::list_rules(&db).unwrap().iter().all(|r| !r.enabled));
|
|
389 |
+ |
assert_eq!(rules::apply_all_rules(&db).unwrap(), 0);
|
|
390 |
+ |
}
|
|
391 |
+ |
|
|
392 |
+ |
#[test]
|
|
393 |
+ |
fn guards_evaluate_after_the_adds() {
|
|
394 |
+ |
// RemoveTag only suppresses an add that already happened, so the guards
|
|
395 |
+ |
// must sort after every keyword rule. Seeding appends in order, which is
|
|
396 |
+ |
// what makes that true; assert it rather than trusting it.
|
|
397 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
398 |
+ |
seed(&db).unwrap();
|
|
399 |
+ |
let rules = rules::list_rules(&db).unwrap();
|
|
400 |
+ |
let last_add = rules
|
|
401 |
+ |
.iter()
|
|
402 |
+ |
.rposition(|r| r.actions.iter().any(|a| matches!(a, RuleAction::AddTag(_))))
|
|
403 |
+ |
.unwrap();
|
|
404 |
+ |
let first_guard = rules
|
|
405 |
+ |
.iter()
|
|
406 |
+ |
.position(|r| {
|
|
407 |
+ |
r.actions
|
|
408 |
+ |
.iter()
|
|
409 |
+ |
.any(|a| matches!(a, RuleAction::RemoveTag(_)))
|
|
410 |
+ |
})
|
|
411 |
+ |
.unwrap();
|
|
412 |
+ |
assert!(
|
|
413 |
+ |
first_guard > last_add,
|
|
414 |
+ |
"a guard at {first_guard} runs before the add at {last_add}"
|
|
415 |
+ |
);
|
|
416 |
+ |
}
|
|
417 |
+ |
}
|