Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/publish/declare.rs

29.0 KiB, 129 runs

created by r1870400018:21500, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! How much a work needed AI, declared in a mark a reader can see.
2//!
3//! A voluntary declaration, not provenance and not detection: it asserts nothing a machine could
4//! check, and is worth exactly what the reader thinks the declarer's word is worth. What the module
5//! does is put the claim where a person meets the work -- beside a post, beside a book, in the site's
6//! own footer -- and link it to the scheme that defines the words.
7//!
8//! # The ladder
9//!
10//! Five rungs, from *AI was unnecessary* to *the human was unnecessary*. The question each one
11//! answers is how much the work **needed** a model, not what share of it a model produced: a share is
12//! a count nobody performs, and the answer that matters to a reader is whether the work could have
13//! existed without the machine.
14//!
15//! # No scheme is named here
16//!
17//! The words are ordinary English and the ladder is a public one, but the site that defines them, and
18//! the artwork that draws them, are a particular scheme's. Both are configuration
19//! ([`DeclareConfig::url`] and [`DeclareConfig::marks`]), so this engine serves a site declaring
20//! under any such scheme, and a site that configures none draws nothing at all.
21//!
22//! # The size rule is structural
23//!
24//! A level is read by counting the pins around the mark, and the count stops being possible as the
25//! mark shrinks -- at half size two neighbouring levels are the same picture, and a mark too small to
26//! read does not *look* broken, which is what makes it worse than no mark. So a mark drawn below
27//! [`MARK_MIN_PX`] carries its declaration in words beside it, and [`Size::alone`] will not return a
28//! wordless mark below that size however small a caller asks for.
29//!
30//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
31//! Anthropic Claude
32
33use crate::srv::cache;
34
35use oxedyne_fe2o3_core::prelude::*;
36use oxedyne_fe2o3_jdat::prelude::*;
37use oxedyne_fe2o3_jdat::string::enc::EncoderConfig;
38use oxedyne_fe2o3_net::http::{
39 fields::{
40 HeaderFieldValue,
41 HeaderName,
42 },
43 msg::HttpMessage,
44};
45use oxedyne_fe2o3_text::doc::html::{
46 escape_attr,
47 escape_text,
48};
49
50use std::collections::BTreeMap;
51
52
53// The smallest a mark may be drawn without its words, in CSS pixels. Below this the pins are no
54// longer countable, so the level is no longer readable, so the words come with it. See the module
55// note: this is the one number in the scheme that a renderer must not treat as advice.
56pub const MARK_MIN_PX: u32 = 40;
57
58// The height a mark is drawn at, in CSS pixels, wherever this module draws one. Above MARK_MIN_PX
59// on purpose: the floor is where a level stops being readable at all, which is a bad place to sit,
60// leaving nothing for a cheap screen, a low zoom or a reader who is not looking closely. One size
61// everywhere also means a mark is the same object on a card, on a post and in a footer, rather
62// than a family of sizes to be judged one placement at a time.
63pub const MARK_SIZE_PX: u32 = 48;
64
65// The marks are line drawings, and a line drawing that has to sit at a byline and on a poster is a
66// vector.
67const MARK_EXT: &str = ".svg";
68
69
70/// How much a work needed AI.
71///
72/// The ladder turns on questions a declarer can answer honestly about their own work: was any used at
73/// all; could **you** have done it; does subtracting the AI leave a lesser work or none at all; was a
74/// person needed to produce it rather than to direct it.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum Level {
77 No, // none was used
78 Some, // it helped, but the declarer could have done this without it
79 With, // parts of this could not have been done without it
80 Mostly, // without AI there would be no work at all
81 Entirely, // no person was needed to produce it
82}
83
84impl Level {
85
86 // Every rung, in order, weakest claim on AI first. What a chooser offers and what a test walks.
87 pub const ALL: [Self; 5] = [Self::No, Self::Some, Self::With, Self::Mostly, Self::Entirely];
88
89 /// The word a record stores and a URL carries.
90 ///
91 /// **These never change.** They are printed into the scheme's badge codes, so a slug is a
92 /// permanent name rather than a spelling this module is free to tidy.
93 pub fn slug(&self) -> &'static str {
94 match self {
95 Self::No => "no-ai",
96 Self::Some => "some-ai",
97 Self::With => "with-ai",
98 Self::Mostly => "mostly-ai",
99 Self::Entirely => "entirely-ai",
100 }
101 }
102
103 /// The declaration in words, as a reader is shown it.
104 ///
105 /// Note the word order of the fourth rung: a work is made **mostly with** AI, not made with mostly
106 /// AI. The first says how much the making leaned on the machine, which is the claim; the second
107 /// says something about the machine, which is not.
108 pub fn words(&self) -> &'static str {
109 match self {
110 Self::No => "Made with no AI",
111 Self::Some => "Made with some AI",
112 Self::With => "Made with AI",
113 Self::Mostly => "Made mostly with AI",
114 Self::Entirely => "Made with AI entirely",
115 }
116 }
117
118 /// The rung a slug names, or nothing where it names none.
119 ///
120 /// **An unknown word is not a level**, deliberately: every other reading would put a declaration on
121 /// a work whose author did not make one, and a declaration nobody made is the one output this
122 /// module must never produce. Undeclared is a state, not a failure.
123 pub fn of(s: &str) -> Option<Self> {
124 Self::ALL.iter().find(|l| l.slug() == s).copied()
125 }
126}
127
128/// What kind of work is being declared for.
129///
130/// A work is often several at once -- a book has a cover and a text, a film has a picture and a score
131/// -- and the honest answer can differ between them, so the mark says which part it speaks for. The
132/// exception is [`Whole`](Self::Whole), for a work that sits at one level throughout.
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum Medium {
135 Whole, // the work entire, at one level; the scheme's umbrella mark
136 Doc, // written prose: a post, a chapter, a document
137 Code, // software
138 Image, // a still picture
139 Audio, // sound
140 Video, // moving picture
141}
142
143impl Medium {
144
145 // Every kind, the whole work first. What a config chooser offers and what a test walks.
146 pub const ALL: [Self; 6] =
147 [Self::Whole, Self::Doc, Self::Code, Self::Image, Self::Audio, Self::Video];
148
149 /// The word a config names it by and a URL carries. The whole work carries none: its declaration
150 /// is about the work rather than a part of it, and a URL saying `/with-ai` is that claim exactly.
151 pub fn slug(&self) -> &'static str {
152 match self {
153 Self::Whole => "",
154 Self::Doc => "doc",
155 Self::Code => "code",
156 Self::Image => "image",
157 Self::Audio => "audio",
158 Self::Video => "video",
159 }
160 }
161
162 /// The kind a word names, or nothing where it names none. An empty word is the whole work.
163 pub fn of(s: &str) -> Option<Self> {
164 Self::ALL.iter().find(|m| m.slug() == s).copied()
165 }
166}
167
168/// A declaration: a rung of the ladder, and the part of the work it speaks for.
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub struct Declaration {
171 pub level: Level,
172 pub medium: Medium,
173}
174
175impl Declaration {
176
177 pub fn new(level: Level, medium: Medium) -> Self {
178 Self { level, medium }
179 }
180
181 /// The path under the scheme's site that defines this declaration, e.g. `/with-ai/doc`.
182 ///
183 /// The web counterpart of the code printed on a badge, and the same address: a reader who wants to
184 /// know what a mark means follows it to the page that says so, whether they scanned it or clicked
185 /// it.
186 pub fn path(&self) -> String {
187 match self.medium {
188 Medium::Whole => fmt!("/{}", self.level.slug()),
189 _ => fmt!("/{}/{}", self.level.slug(), self.medium.slug()),
190 }
191 }
192
193 /// The artwork's file name, without a directory, e.g. `doc-with-ai.svg`.
194 ///
195 /// Built from the two permanent slugs rather than from the artwork's own file names, which belong
196 /// to whoever drew it and have been renamed at least once. A site ships the thirty marks under
197 /// this rule and the rule is the whole of the contract.
198 pub fn mark_file(&self) -> String {
199 match self.medium {
200 Medium::Whole => fmt!("{}{}", self.level.slug(), MARK_EXT),
201 _ => fmt!("{}-{}{}", self.medium.slug(), self.level.slug(), MARK_EXT),
202 }
203 }
204}
205
206/// A named thing on the site that carries a declaration of its own.
207///
208/// Posts hold their level in their own record, because a post is written here. This is for everything
209/// else a site shows -- a book, a project, a product -- which is authored somewhere else and needs
210/// only somewhere to keep the one field, and a place for an admin to set it.
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct Declarable {
213 pub key: String, // what the site calls it, in the store and in JSON; never shown
214 pub name: String, // what an admin sees when choosing its level
215 pub medium: Medium, // decides which mark it wears
216}
217
218/// A site's declaration settings: the scheme it speaks, where its artwork is, what the site says
219/// about itself, and what else on the site may be declared for.
220///
221/// Absent from a config, every field is empty and [`is_on`](Self::is_on) is false, which draws no mark
222/// anywhere. A site declares when it says where the scheme lives and where the artwork is, and not
223/// before -- a mark whose artwork 404s is worse than no mark, and a mark linking nowhere explains
224/// nothing.
225#[derive(Clone, Debug, Default, Eq, PartialEq)]
226pub struct DeclareConfig {
227 // The scheme's own site and where the artwork is served from, both without a trailing slash. A
228 // mark links to the first, with the declaration's own path after it.
229 pub url: String, // e.g. `https://example.org`
230 pub marks: String, // e.g. `/assets/marks`
231 // What the site says about itself, drawn in its footer. Nothing where the site declares nothing
232 // about itself, which is not the same as declaring that it used none.
233 pub site: Option<Declaration>,
234 pub items: Vec<Declarable>, // what an admin may set a level for, in the order offered
235}
236
237impl DeclareConfig {
238
239 pub fn is_on(&self) -> bool {
240 !self.url.is_empty() && !self.marks.is_empty()
241 }
242
243 pub fn item(&self, key: &str) -> Option<&Declarable> {
244 self.items.iter().find(|i| i.key == key)
245 }
246
247 /// Reads the block a config writes.
248 ///
249 /// ```text
250 /// "declare": {
251 /// "url": "https://example.org",
252 /// "marks": "/assets/marks",
253 /// "site": "with-ai/code",
254 /// "items": [
255 /// { "key": "widget", "name": "The Widget", "medium": "doc" }
256 /// ]
257 /// }
258 /// ```
259 ///
260 /// A level or a medium this module does not know is an error rather than a silent default: a
261 /// mistyped rung would otherwise publish a claim the operator did not make.
262 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
263 let get_str = |key: &str| -> Outcome<String> {
264 match m.get(&dat!(key)) {
265 Some(Dat::Str(s)) => Ok(s.clone()),
266 None => Ok(String::new()),
267 _ => Err(err!(
268 "DeclareConfig: '{}' must be a string.", key;
269 Invalid, Input, Mismatch)),
270 }
271 };
272
273 // Both are prefixes something is appended to, so a trailing slash would double the one in the
274 // path that follows.
275 let mut url = res!(get_str("url"));
276 while url.ends_with('/') {
277 url.pop();
278 }
279 let mut marks = res!(get_str("marks"));
280 while marks.ends_with('/') {
281 marks.pop();
282 }
283
284 let site_str = res!(get_str("site"));
285 let site = if site_str.is_empty() {
286 None
287 } else {
288 Some(res!(parse_declaration(&site_str)))
289 };
290
291 let items = match m.get(&dat!("items")) {
292 Some(Dat::List(list)) => res!(declarables(list)),
293 Some(Dat::Vek(vek)) => res!(declarables(vek.as_slice())),
294 None => Vec::new(),
295 _ => return Err(err!(
296 "DeclareConfig: 'items' must be a list of maps.";
297 Invalid, Input, Mismatch)),
298 };
299
300 Ok(Self { url, marks, site, items })
301 }
302}
303
304/// A declaration written as one word, `<level>` or `<level>/<medium>`.
305///
306/// One field rather than two, because the two are never usefully set apart: a level with no medium is
307/// a declaration about the whole work, which the grammar says by leaving the medium off.
308fn parse_declaration(s: &str) -> Outcome<Declaration> {
309 let (level_str, medium_str) = match s.split_once('/') {
310 Some((l, m)) => (l, m),
311 None => (s, ""),
312 };
313 let level = res!(Level::of(level_str).ok_or_else(|| err!(
314 "DeclareConfig: '{}' is not a declaration level. The levels are {}.",
315 level_str, slug_list(); Invalid, Input)));
316 let medium = res!(Medium::of(medium_str).ok_or_else(|| err!(
317 "DeclareConfig: '{}' is not a kind of work.", medium_str; Invalid, Input)));
318 Ok(Declaration { level, medium })
319}
320
321/// Every level's slug, for the error a mistyped one raises. An operator who typed the wrong word is
322/// owed the list of right ones.
323fn slug_list() -> String {
324 Level::ALL.iter().map(|l| l.slug()).collect::<Vec<_>>().join(", ")
325}
326
327fn declarables(items: &[Dat]) -> Outcome<Vec<Declarable>> {
328 let mut out = Vec::new();
329 for item in items {
330 let m = match item {
331 Dat::Map(m) => m,
332 _ => return Err(err!(
333 "DeclareConfig: every 'items' entry must be a map.";
334 Invalid, Input, Mismatch)),
335 };
336 let field = |key: &str| -> Outcome<String> {
337 match m.get(&dat!(key)) {
338 Some(Dat::Str(s)) => Ok(s.clone()),
339 None => Ok(String::new()),
340 _ => Err(err!(
341 "DeclareConfig: an item's '{}' must be a string.", key;
342 Invalid, Input, Mismatch)),
343 }
344 };
345 let key = res!(field("key"));
346 if key.is_empty() {
347 return Err(err!(
348 "DeclareConfig: every declarable item needs a 'key', which is how its level is \
349 stored and how a page asks for it."; Invalid, Input, Missing));
350 }
351 let name = res!(field("name"));
352 let medium_str = res!(field("medium"));
353 let medium = res!(Medium::of(&medium_str).ok_or_else(|| err!(
354 "DeclareConfig: '{}' is not a kind of work, for item '{}'.", medium_str, key;
355 Invalid, Input)));
356 out.push(Declarable {
357 // A name nobody set reads as the key, which is at least a word an admin recognises.
358 name: if name.is_empty() { key.clone() } else { name },
359 key,
360 medium,
361 });
362 }
363 Ok(out)
364}
365
366/// How a mark is set on the page.
367///
368/// The two are not interchangeable and the difference is not taste: see [`MARK_MIN_PX`].
369#[derive(Clone, Copy, Debug, Eq, PartialEq)]
370pub enum Size {
371 Alone(u32), // CSS pixels, at least MARK_MIN_PX
372 // The mark at the given size with its declaration in words beside it. Words are not only for a
373 // mark too small to read: a footer saying what a whole site is has room for the sentence and a
374 // reason to spell it out; a mark beside a reading time does not, and the words there would say
375 // the same thing on every card.
376 WithWords(u32),
377}
378
379impl Size {
380
381 /// A mark alone at the given size, **or with its words where that size is too small to read**.
382 ///
383 /// The rule made structural: a caller asking for a wordless mark at 16 px does not get one, it
384 /// gets a legible declaration. There is no way to spell the illegible arrangement.
385 pub fn alone(px: u32) -> Self {
386 if px < MARK_MIN_PX {
387 Self::WithWords(px)
388 } else {
389 Self::Alone(px)
390 }
391 }
392}
393
394/// The mark for a declaration, as HTML: a link to the scheme, wearing the artwork.
395///
396/// Empty where the site declares nothing ([`DeclareConfig::is_on`]), so every caller can ask
397/// unconditionally and a site that has not configured the scheme simply has no marks.
398///
399/// # Why the artwork is a mask and not a picture
400///
401/// The mark has to sit on a dark site and a light one and read as the site's own furniture, and the
402/// artwork is one set of files drawn in black. So it is used as a **mask** over `currentColor`: the
403/// shape comes from the file, the colour from whatever the surrounding text is. One asset, every
404/// palette, and no per-site copy of the artwork to keep in step.
405///
406/// The mask carries no meaning to a reader who cannot see it, so the accessible name is on the link,
407/// and the shape itself is hidden from assistive technology rather than announced as an image with no
408/// description.
409pub fn mark_html(cfg: &DeclareConfig, decl: Declaration, size: Size, class: &str) -> String {
410 if !cfg.is_on() {
411 return String::new();
412 }
413 let mut s = String::new();
414 s.push_str("<a class=\"ai-mark");
415 match size {
416 Size::Alone(_) => s.push_str(" ai-mark-alone"),
417 Size::WithWords(_) => s.push_str(" ai-mark-inline"),
418 }
419 if !class.is_empty() {
420 s.push(' ');
421 escape_attr(&mut s, class);
422 }
423 s.push_str("\" href=\"");
424 escape_attr(&mut s, &fmt!("{}{}", cfg.url, decl.path()));
425 // A reader following a mark has not finished with the page they were reading, and the scheme is a
426 // third-party site: a new tab, and no window handle back to this one.
427 s.push_str("\" target=\"_blank\" rel=\"noopener\" title=\"");
428 escape_attr(&mut s, decl.level.words());
429 s.push_str("\" aria-label=\"");
430 escape_attr(&mut s, decl.level.words());
431 s.push_str("\">");
432
433 // The shape. Its size rides in a custom property rather than a class per size, because the sizes
434 // are a placement decision and the stylesheet should not have to grow a class for each one.
435 s.push_str("<span class=\"ai-mark-ink\" aria-hidden=\"true\" style=\"");
436 match size {
437 Size::Alone(px) | Size::WithWords(px) => s.push_str(&fmt!("--ai-mark-size:{}px;", px)),
438 }
439 let url = fmt!("{}/{}", cfg.marks, decl.mark_file());
440 s.push_str(&fmt!(
441 "-webkit-mask-image:url('{0}');mask-image:url('{0}')", css_url(&url)));
442 s.push_str("\"></span>");
443
444 // The words, where the caller asked for them.
445 if matches!(size, Size::WithWords(_)) {
446 s.push_str("<span class=\"ai-mark-words\">");
447 escape_text(&mut s, decl.level.words());
448 s.push_str("</span>");
449 }
450 s.push_str("</a>");
451 s
452}
453
454/// A URL fit to sit inside a CSS `url('…')` in a style attribute.
455///
456/// Two escapes, not one: the attribute is escaped on the way out by the caller's `escape_attr`, but
457/// the CSS string inside it has its own quoting, and a value carrying a quote or a backslash would
458/// otherwise close the string and leave the rest as declarations. The paths this takes are built from
459/// config, and config is not a trusted source of syntax.
460fn css_url(url: &str) -> String {
461 let mut out = String::new();
462 for c in url.chars() {
463 match c {
464 '\\' | '\'' | '"' => {
465 out.push('\\');
466 out.push(c);
467 },
468 // A newline would end the declaration; a parenthesis would end the `url()`.
469 '\n' | '\r' | '(' | ')' => {},
470 _ => out.push(c),
471 }
472 }
473 // The attribute's own escaping happens where this is written into one.
474 out.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
475}
476
477/// Serves the site's declarations as JSON, for a page that draws its own.
478///
479/// Everything resolved -- the words, the artwork's URL, the link -- rather than the two slugs and a
480/// rule to apply to them. There is one rule for how a mark is built and it lives here; a client
481/// reimplementing it is a second rule that will drift from this one.
482///
483/// An item the admin has not set a level for carries no `level` key at all, the empty idiom the rest
484/// of this module keeps: absent and undeclared are the same thing said once.
485pub fn serve_json(
486 cfg: &DeclareConfig,
487 levels: &BTreeMap<String, Level>,
488 id: &str,
489)
490 -> Outcome<HttpMessage>
491{
492 let resolved = |d: Declaration| -> Vec<(Dat, Dat)> {
493 vec![
494 (dat!("level"), dat!(d.level.slug().to_string())),
495 (dat!("medium"), dat!(d.medium.slug().to_string())),
496 (dat!("words"), dat!(d.level.words().to_string())),
497 (dat!("mark"), dat!(fmt!("{}/{}", cfg.marks, d.mark_file()))),
498 (dat!("href"), dat!(fmt!("{}{}", cfg.url, d.path()))),
499 ]
500 };
501
502 // The ladder itself, so a page drawing a chooser -- a composer, an admin panel -- offers exactly
503 // the rungs this version knows. Without it every client keeps its own copy of the five, and a copy
504 // is a thing that drifts.
505 let vocabulary = Level::ALL.iter()
506 .map(|l| create_dat_ordmap(vec![
507 (dat!("level"), dat!(l.slug().to_string())),
508 (dat!("words"), dat!(l.words().to_string())),
509 ]))
510 .collect::<Vec<_>>();
511
512 let mut fields = vec![
513 (dat!("url"), dat!(cfg.url.clone())),
514 (dat!("marks"), dat!(cfg.marks.clone())),
515 (dat!("levels"), Dat::List(vocabulary)),
516 ];
517 // What the site says about itself. Absent where it says nothing.
518 if let Some(d) = cfg.site {
519 fields.push((dat!("site"), create_dat_ordmap(resolved(d))));
520 }
521 let items = cfg.items.iter()
522 .map(|item| {
523 let mut f = vec![
524 (dat!("key"), dat!(item.key.clone())),
525 (dat!("name"), dat!(item.name.clone())),
526 (dat!("medium"), dat!(item.medium.slug().to_string())),
527 ];
528 if let Some(level) = levels.get(&item.key) {
529 f.extend(resolved(Declaration::new(*level, item.medium)));
530 }
531 create_dat_ordmap(f)
532 })
533 .collect::<Vec<_>>();
534 fields.push((dat!("items"), Dat::List(items)));
535
536 let json_cfg = EncoderConfig::<(), ()>::json(None);
537 let body_json = res!(create_dat_ordmap(fields).encode_string_with_config(&json_cfg));
538
539 info!("{}: publish: declarations, {} item(s)", id, cfg.items.len());
540
541 let mut resp = HttpMessage::ok_respond_with_text(body_json);
542 resp = resp.with_field(
543 HeaderName::ContentType,
544 HeaderFieldValue::Generic(fmt!("application/json")),
545 );
546 // A level changed in the console must show on the site at once. A page holding yesterday's copy
547 // would draw a declaration its author has since corrected, which is the one staleness that matters
548 // here.
549 Ok(cache::generated(resp))
550}
551
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 fn cfg() -> DeclareConfig {
558 DeclareConfig {
559 url: fmt!("https://example.org"),
560 marks: fmt!("/assets/marks"),
561 site: Some(Declaration::new(Level::With, Medium::Code)),
562 items: vec![Declarable {
563 key: fmt!("widget"),
564 name: fmt!("The Widget"),
565 medium: Medium::Doc,
566 }],
567 }
568 }
569
570 /// The slugs are printed into badge codes and cannot be revised. Pinned here so a tidy-up of the
571 /// spelling fails a test rather than breaking every code already in the world.
572 #[test]
573 fn test_the_slugs_are_permanent_00() -> Outcome<()> {
574 let got = Level::ALL.iter().map(|l| l.slug()).collect::<Vec<_>>();
575 assert_eq!(got, vec!["no-ai", "some-ai", "with-ai", "mostly-ai", "entirely-ai"]);
576 // Round trip: every slug names back the rung it came from, and nothing else does.
577 for l in Level::ALL {
578 assert_eq!(Level::of(l.slug()), Some(l), "'{}' did not read back", l.slug());
579 }
580 assert_eq!(Level::of("mostly"), None, "a partial word named a level");
581 assert_eq!(Level::of(""), None, "an empty word named a level");
582 Ok(())
583 }
584
585 /// The fourth rung says a work was made *mostly with* AI. The other word order says something
586 /// about the machine rather than about the making, and is not the claim.
587 #[test]
588 fn test_the_fourth_rung_keeps_its_word_order_01() -> Outcome<()> {
589 assert_eq!(Level::Mostly.words(), "Made mostly with AI");
590 Ok(())
591 }
592
593 /// A declaration addresses the scheme's page for it, and wears the artwork named by the same two
594 /// slugs. The whole work carries no medium in either.
595 #[test]
596 fn test_a_declaration_addresses_its_page_and_its_artwork_02() -> Outcome<()> {
597 let d = Declaration::new(Level::With, Medium::Doc);
598 assert_eq!(d.path(), "/with-ai/doc");
599 assert_eq!(d.mark_file(), "doc-with-ai.svg");
600 let w = Declaration::new(Level::Entirely, Medium::Whole);
601 assert_eq!(w.path(), "/entirely-ai");
602 assert_eq!(w.mark_file(), "entirely-ai.svg");
603 Ok(())
604 }
605
606 /// A wordless mark below the countable size is not a thing this module can be asked to draw. The
607 /// caller asking for one gets the legible arrangement instead.
608 #[test]
609 fn test_a_small_mark_cannot_lose_its_words_03() -> Outcome<()> {
610 assert_eq!(Size::alone(MARK_MIN_PX), Size::Alone(MARK_MIN_PX));
611 assert_eq!(Size::alone(MARK_SIZE_PX), Size::Alone(MARK_SIZE_PX));
612 assert_eq!(Size::alone(MARK_MIN_PX - 1), Size::WithWords(MARK_MIN_PX - 1),
613 "a mark went wordless below the floor");
614 assert_eq!(Size::alone(16), Size::WithWords(16));
615 // What the module actually draws sits above the floor rather than on it.
616 assert!(MARK_SIZE_PX >= MARK_MIN_PX, "the drawn size is below the readable floor");
617
618 // And the words really are drawn in that arrangement, not merely chosen.
619 let html = mark_html(&cfg(), Declaration::new(Level::Some, Medium::Doc), Size::alone(16), "");
620 assert!(html.contains("Made with some AI"), "the small mark carried no words: {}", html);
621 assert!(html.contains("ai-mark-inline"), "the small mark is not the inline arrangement: {}", html);
622 Ok(())
623 }
624
625 /// The mark links to the scheme's page for exactly the declaration it draws, wears the artwork as
626 /// a mask so it takes the site's own colour, and says in words what it is to a reader who cannot
627 /// see it.
628 #[test]
629 fn test_the_mark_links_and_names_itself_04() -> Outcome<()> {
630 let html = mark_html(&cfg(), Declaration::new(Level::Mostly, Medium::Code), Size::alone(44), "foot-mark");
631 assert!(html.contains("href=\"https://example.org/mostly-ai/code\""), "wrong link: {}", html);
632 assert!(html.contains("mask-image:url('/assets/marks/code-mostly-ai.svg')"), "wrong artwork: {}", html);
633 assert!(html.contains("-webkit-mask-image"), "no mask for the older engine: {}", html);
634 assert!(html.contains("aria-label=\"Made mostly with AI\""), "no accessible name: {}", html);
635 assert!(html.contains("--ai-mark-size:44px"), "the size did not reach the mark: {}", html);
636 assert!(html.contains("foot-mark"), "the caller's class was dropped: {}", html);
637 // The shape says nothing to a reader who cannot see it, and must not be announced twice.
638 assert!(html.contains("aria-hidden=\"true\""), "the shape is not hidden from assistive tech: {}", html);
639 Ok(())
640 }
641
642 /// A site that has not configured the scheme draws nothing at all, rather than a mark whose
643 /// artwork is missing and whose link goes nowhere.
644 #[test]
645 fn test_an_unconfigured_site_draws_no_mark_05() -> Outcome<()> {
646 let off = DeclareConfig::default();
647 assert!(!off.is_on());
648 let html = mark_html(&off, Declaration::new(Level::No, Medium::Doc), Size::alone(44), "");
649 assert!(html.is_empty(), "an unconfigured site drew a mark: {}", html);
650 // Half-configured is still off: artwork with no scheme explains nothing, and a scheme with no
651 // artwork draws nothing.
652 let half = DeclareConfig { url: fmt!("https://example.org"), ..Default::default() };
653 assert!(!half.is_on(), "a site with no artwork declared anyway");
654 Ok(())
655 }
656
657 /// The config block reads the site's own declaration and its declarable things, and refuses a rung
658 /// it does not know rather than quietly picking one.
659 #[test]
660 fn test_the_config_block_reads_and_refuses_06() -> Outcome<()> {
661 let m = mapdat!{
662 "url" => "https://example.org/",
663 "marks" => "/assets/marks/",
664 "site" => "with-ai/code",
665 "items" => listdat![
666 mapdat!{ "key" => "widget", "name" => "The Widget", "medium" => "doc" },
667 mapdat!{ "key" => "engine", "medium" => "code" },
668 ],
669 }.get_map().unwrap();
670 let c = res!(DeclareConfig::from_datmap(&m));
671 // The trailing slashes are gone, or every path built from them would double one.
672 assert_eq!(c.url, "https://example.org");
673 assert_eq!(c.marks, "/assets/marks");
674 assert_eq!(c.site, Some(Declaration::new(Level::With, Medium::Code)));
675 assert_eq!(c.items.len(), 2);
676 assert_eq!(c.items[0].name, "The Widget");
677 // An item with no name of its own is offered under its key, not under nothing.
678 assert_eq!(c.items[1].name, "engine");
679 assert_eq!(c.items[1].medium, Medium::Code);
680
681 let bad = mapdat!{
682 "url" => "https://example.org",
683 "marks" => "/assets/marks",
684 "site" => "quite-a-lot-of-ai",
685 }.get_map().unwrap();
686 assert!(DeclareConfig::from_datmap(&bad).is_err(), "a level nobody defined was accepted");
687 Ok(())
688 }
689
690 /// The JSON hands a client the finished mark rather than the parts, and says nothing at all about
691 /// an item whose level nobody has set.
692 #[test]
693 fn test_the_json_resolves_a_mark_and_omits_an_undeclared_one_07() -> Outcome<()> {
694 let mut levels = BTreeMap::new();
695 levels.insert(fmt!("widget"), Level::Some);
696 let resp = res!(serve_json(&cfg(), &levels, "test"));
697 let body = String::from_utf8_lossy(&resp.body).to_string();
698 assert!(body.contains(r#""mark": "/assets/marks/doc-some-ai.svg""#), "no artwork URL: {}", body);
699 assert!(body.contains(r#""href": "https://example.org/some-ai/doc""#), "no link: {}", body);
700 assert!(body.contains(r#""words": "Made with some AI""#), "no words: {}", body);
701 // The site's own declaration rides alongside, so a footer needs one fetch and not two.
702 assert!(body.contains(r#""/assets/marks/code-with-ai.svg""#), "no site mark: {}", body);
703
704 // The vocabulary and the site's own declaration both carry the word `level`, so the item is
705 // read where it lives rather than by searching the whole body for it.
706 let resp = res!(serve_json(&cfg(), &BTreeMap::new(), "test"));
707 let body = String::from_utf8_lossy(&resp.body).to_string();
708 let items = res!(body.split_once(r#""items""#).map(|(_, rest)| rest.to_string())
709 .ok_or_else(|| err!("the body carried no items at all: {}", body; Missing)));
710 assert!(items.contains(r#""key": "widget""#), "the declarable itself went missing: {}", body);
711 assert!(!items.contains(r#""level""#), "an unset item was given a level: {}", body);
712 // The chooser a client draws is still offered every rung -- an undeclared item is not a site
713 // that has forgotten what the rungs are.
714 assert!(body.contains(r#""words": "Made mostly with AI""#), "no vocabulary: {}", body);
715 Ok(())
716 }
717
718 /// A path from config cannot break out of the CSS string it is written into.
719 #[test]
720 fn test_an_artwork_path_cannot_escape_its_css_string_08() -> Outcome<()> {
721 let c = DeclareConfig {
722 marks: fmt!("/a'); background:url('http://elsewhere/x"),
723 ..cfg()
724 };
725 let html = mark_html(&c, Declaration::new(Level::No, Medium::Doc), Size::alone(44), "");
726 assert!(!html.contains("background:url('http"), "a config path opened a second declaration: {}", html);
727 Ok(())
728 }
729}