Skip to main content

max / makenotwork

29.4 KB · 727 lines History Blame Raw
1 //! The Askama entry point for a described upload.
2 //!
3 //! Shape 3 of the conversion plan (wiki `mnw-shape-conversion-plans`), and the
4 //! one that had to wait for two vocabulary rulings rather than one. `f7261a5a`
5 //! settled what an upload says about itself: what it takes
6 //! ([`Field::upload`] plus its accept list), how many at a time
7 //! ([`Field::many`]), where the bytes go (the field's action) and that it
8 //! waits ([`Action::awaiting`]). `a81384d4` settled who makes the calls, which
9 //! none of those four axes covered: an MNW upload is presign, then a PUT
10 //! straight to storage from the browser, then confirm, and a renderer posting
11 //! the field at the first of those would be wrong about the response shape and
12 //! about where the bytes end up. [`Action::by_host`] is the answer. The
13 //! renderer emits the address as `data-sends` and performs nothing;
14 //! `static/upload.js` reads it and runs the chain.
15 //!
16 //! # What is described here and what stays the host's
17 //!
18 //! Described: the accept list, the multiplicity, the destination, the payload
19 //! that rides with it, and that the call waits. Every one of those was
20 //! hand-written in a template before this, three times over with three
21 //! spellings of the same accept list.
22 //!
23 //! Host: the drop gesture and the progress bar. Neither is an omission.
24 //! `makeover-layout`'s `FieldKind::File` doc says a drop area, a picker button
25 //! and a typed path are one field and that the gesture "is not described for
26 //! the reason no gesture is", so the dragging stays in `upload.js`. Progress is
27 //! `Awaiting`'s ruling: the mark says the call waits and carries a measured
28 //! size when there is one, and the renderer observes the rest. The server
29 //! renders this markup before a file exists, so there is no size to write down
30 //! and the mark is the unmeasured one.
31 //!
32 //! # Why a function per surface rather than one builder
33 //!
34 //! Three call sites, each with a fixed accept list that is a fact about MNW
35 //! rather than about the template it sits in. A generic entry point would put
36 //! `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` back in Askama, in two
37 //! places, which is the duplication this shape exists to remove. So the lists
38 //! live here once and each surface is named for what it is.
39
40 use makeover_layout::{Family, FieldKind};
41 use quasi_router::{Accepted, Action, Field, Node, Repeat};
42
43 /// Every suffix a version file may carry.
44 ///
45 /// One list, read by both version surfaces. A suffix names no family by ruling
46 /// (`f7261a5a`: a suffix-to-family table rots), so these are `Suffix` and the
47 /// reader gets no media disclosure from them, which is right for a build
48 /// artifact.
49 fn version_suffixes() -> Vec<Accepted> {
50 [
51 ".zip",
52 ".dmg",
53 ".exe",
54 ".appimage",
55 ".deb",
56 ".tar.gz",
57 ".clap",
58 ".vst3",
59 ]
60 .into_iter()
61 .map(Accepted::suffix)
62 .collect()
63 }
64
65 /// The drop area an upload field sits in, and the field itself.
66 ///
67 /// The wrapper is this app's and the group inside it is the renderer's, the
68 /// same division `widgets::carousel` and `quasi::rich_field` make. What the
69 /// wrapper carries is the gesture, which is why it is here: `upload.js` binds
70 /// drag, drop and click-to-open on `.file-upload-area`, and finds the
71 /// destination on the field wrapper the renderer emitted inside it.
72 ///
73 /// `fallback` is what to call the bytes when the browser offers no media type
74 /// for them, which happens for the suffixes S3 has never heard of. It is a host
75 /// fact and not a described one: the presigned URL binds the content type, so
76 /// the PUT has to send back the exact string the presign was asked for, and
77 /// `S3Client::validate_content_type` refuses `application/octet-stream` for an
78 /// item's audio. It rides as an attribute here rather than as a literal in
79 /// Askama so that both halves of the pair stay in one file.
80 fn area(prompt: &str, hint: &str, fallback: &str, field: Field) -> String {
81 use quasi_axum::Serves as _;
82
83 // No shell: a fragment landing inside a document Askama already built.
84 let inner = quasi_webview::Webview::new().fragment(&Node::field(field));
85 format!(
86 "<div class=\"file-upload-area\" data-upload-fallback=\"{}\">\
87 <div class=\"upload-text\">{}</div>\
88 <div class=\"upload-hint\">{}</div>{inner}</div>",
89 crate::helpers::escape_html(fallback),
90 crate::helpers::escape_html(prompt),
91 crate::helpers::escape_html(hint),
92 )
93 }
94
95 /// An item's audio file, replaced whole each time.
96 ///
97 /// One file, any audio media type, landing through `/api/upload/presign`. The
98 /// two values the chain needs beyond the file ride as the action's parameters
99 /// and reach the host as `data-vals`, which is the whole of what the old
100 /// `data-item-id` attribute and the hard-coded `file_type: 'audio'` literal
101 /// were doing.
102 #[must_use]
103 pub fn audio(item_id: &str) -> String {
104 let field = Field::upload("audio", "Audio file", [Accepted::family(Family::Audio)]).writes(
105 Action::post("/api/upload/presign")
106 .with("item_id", item_id)
107 .with("file_type", "audio")
108 .awaiting()
109 .by_host(),
110 );
111
112 area(
113 "Drop audio file here or choose one to upload",
114 "Supports MP3, WAV, FLAC, M4A up to 500 MB",
115 "audio/mpeg",
116 field,
117 )
118 }
119
120 /// One file for a version that already exists.
121 ///
122 /// The same accept list as [`version_queue`] and a destination of its own,
123 /// since the version this belongs to is already in the database and its id is
124 /// in the address rather than in the payload.
125 #[must_use]
126 pub fn existing_version(version_id: &str) -> String {
127 let field = Field::upload("version-file", "Version file", version_suffixes()).writes(
128 Action::post(format!("/api/versions/{version_id}/upload/presign"))
129 .awaiting()
130 .by_host(),
131 );
132
133 area(
134 "Drop file to upload for this version",
135 "ZIP, DMG, EXE, AppImage, DEB, tar.gz, CLAP, VST3",
136 "application/octet-stream",
137 field,
138 )
139 }
140
141 /// The one image a project or an item shows for itself.
142 ///
143 /// One file, three media types, and an entity named the way its presign route
144 /// reads it: `project_id` or `item_id`, riding in the payload as the action's
145 /// parameter. The two wizard steps shared one implementation before this
146 /// (`frontend/src/islands/uploader/image-uploader.ts`, deleted with the
147 /// conversion), so they convert as one function rather than two.
148 ///
149 /// What comes back is a URL, and where it lands is the host's: a hidden input
150 /// that the wizard form posts, and one or two places that show the picture.
151 /// Both are the same class of fact as `data-upload-goes` and
152 /// `data-upload-refreshes`, so both are attributes on the surface rather than
153 /// members of the description. See `static/upload.js`.
154 #[must_use]
155 pub fn image(presign: &str, id_field: &str, id_value: &str) -> String {
156 let field = Field::upload(
157 "cover-image",
158 "Cover image",
159 [
160 Accepted::media_type("image/jpeg"),
161 Accepted::media_type("image/png"),
162 Accepted::media_type("image/webp"),
163 ],
164 )
165 .writes(
166 Action::post(presign)
167 .with(id_field, id_value)
168 .awaiting()
169 .by_host(),
170 );
171
172 area(
173 "Drop an image here or choose one to upload",
174 "JPG, PNG or WebP, square and at least 400x400px, up to 10 MB",
175 "image/jpeg",
176 field,
177 )
178 }
179
180 /// The files a new version is being built from, picked before it exists.
181 ///
182 /// Several at once, and deliberately with no destination: nothing can be sent
183 /// until the reader has named the version and labelled each file, so the
184 /// address belongs to [`version_upload_all`] and not to this field. What is
185 /// described is what it takes, how many, and the standing help beside it,
186 /// which is the half that was written out by hand in the template.
187 ///
188 /// The hint arrived here rather than staying markup because the template was
189 /// writing a second `Files` label above the one this field already emits. A
190 /// field owns its own label and its own standing help; anything writing either
191 /// beside it is describing the same question twice.
192 #[must_use]
193 pub fn version_queue() -> String {
194 use quasi_axum::Serves as _;
195
196 let field = Field::upload("version-files", "Files", version_suffixes())
197 .many()
198 .hint(
199 "Add one file per platform. Each gets its own label \
200 (e.g. \"macOS (arm)\", \"Linux (x86_64)\").",
201 );
202 quasi_webview::Webview::new().fragment(&Node::field(field))
203 }
204
205 /// What a new version carries besides its files.
206 ///
207 /// Two ordinary questions, and they were only ever markup because the surface
208 /// around them was. Their names are the ids `item-upload.js` reads them back
209 /// by: a field's name is its id in this renderer, which is the same fact
210 /// [`version_queue`]'s picker already relies on.
211 ///
212 /// Emitted as two fragments side by side rather than one region, because the
213 /// grid they sit in is the template's and a region would put a box around
214 /// them.
215 #[must_use]
216 pub fn version_details() -> String {
217 use quasi_axum::Serves as _;
218
219 let mut number = Field::new(FieldKind::Text, "new-version-number", "Version Number");
220 number.placeholder = Some("e.g., 1.0".to_owned());
221
222 let mut changelog = Field::new(FieldKind::Text, "version-changelog", "Notes (optional)");
223 changelog.placeholder = Some("What changed in this version...".to_owned());
224
225 let mut html = quasi_webview::Webview::new().fragment(&Node::field(number));
226 html.push_str(&quasi_webview::Webview::new().fragment(&Node::field(changelog)));
227 html
228 }
229
230 /// The files the reader has picked, one slot each, with the label that names
231 /// the platform it is for.
232 ///
233 /// The shape `7f04f751` counted and could not say, until quasi 0.96 said it.
234 /// Three members carry it and each answers a different half of what the
235 /// hand-written table was doing:
236 ///
237 /// - [`Repeat::added_by`] names [`version_queue`]'s picker as where the slots
238 /// come from, so no renderer offers an add control. A blank row here is
239 /// nothing a reader can fill: there is no way to type a file.
240 /// - [`Instance::called`] names each slot by its file, set by the host as it
241 /// takes them, because only the picker knows what a `File` is called. The
242 /// ordinal would have put "File 2" where "track-arm.dmg" goes.
243 /// - [`Progress`] is the per-slot status the run sets, which is what the
244 /// second copy of this list inside the progress panel used to draw.
245 ///
246 /// The question itself is the label, and it is the only thing here that is
247 /// answered: the file name is what the slot *is*, and the bytes go to S3
248 /// through a presigned PUT rather than through this form.
249 #[must_use]
250 pub fn version_file_queue() -> String {
251 use quasi_axum::Serves as _;
252
253 let mut label = Field::new(FieldKind::Text, "version-file", "File");
254 label.placeholder = Some("e.g., macOS (arm)".to_owned());
255 let field = label.repeating(Repeat::new().added_by("version-files").removing("Remove"));
256 quasi_webview::Webview::new().fragment(&Node::field(field))
257 }
258
259 /// The control that starts the run, and the address it starts at.
260 ///
261 /// [`Action::by_host`] for the reason every other upload here is: the host
262 /// makes three calls per file behind this one address, and a renderer posting
263 /// the form at the first of them would be wrong about the response shape and
264 /// about where the bytes go. What is described is where the run begins and
265 /// that it waits; the sequence stays `item-upload.js`.
266 ///
267 /// This is what took the last string literal for a route out of that file. It
268 /// built `/api/items/<id>/versions` by hand from a `data-item-id` attribute on
269 /// an ancestor, which is an address written down twice in two languages.
270 #[must_use]
271 pub fn version_upload_all(item_id: &str) -> String {
272 use quasi_axum::Serves as _;
273
274 let action = Action::post(format!("/api/items/{item_id}/versions"))
275 .awaiting()
276 .by_host();
277 quasi_webview::Webview::new().fragment(&Node::act("Upload All", action))
278 }
279
280 #[cfg(test)]
281 mod tests {
282 /// The three members `7f04f751` waited on, on the surface that wanted all
283 /// of them. Read together they are the conversion: before this the rows
284 /// were built by `addFileRow` with inline styles, the label input was
285 /// found by a hand-written class, and nothing said a slot could fail.
286 #[test]
287 fn the_picked_file_queue_says_where_its_slots_come_from_and_names_them_by_file() {
288 let html = super::version_file_queue();
289
290 // The slots come from the picker above, so no renderer offers an add
291 // control: there is no blank a reader could fill.
292 assert!(
293 html.contains(r#"data-repeat-add-from="version-files""#),
294 "{html}"
295 );
296 // The add control itself, which is a different attribute from the one
297 // above and would otherwise match it as a prefix.
298 assert!(!html.contains("field-repeat-add"), "{html}");
299 // The question that is actually answered is the label.
300 assert!(html.contains(r#"data-repeat="version-file""#), "{html}");
301 assert!(html.contains("data-repeat-slots"), "{html}");
302 // And the blank the host clones per picked file.
303 assert!(html.contains("<template"), "{html}");
304 }
305
306 /// The four axes `f7261a5a` settled, on the surface that has all of them.
307 /// Read together they are the whole point of the conversion: before this
308 /// the accept list was an attribute in Askama, the destination was a
309 /// string literal in `item-upload.js`, and nothing said the call waits.
310 #[test]
311 fn an_audio_upload_says_what_it_takes_where_it_goes_and_that_it_waits() {
312 let html = super::audio("11111111-1111-1111-1111-111111111111");
313
314 assert!(html.contains(r#"type="file""#), "{html}");
315 assert!(html.contains(r#"accept="audio/*""#), "{html}");
316 assert!(!html.contains(" multiple"), "{html}");
317 assert!(
318 html.contains(r#"data-sends="/api/upload/presign""#),
319 "{html}"
320 );
321 assert!(html.contains("data-awaiting="), "{html}");
322 }
323
324 /// The host makes this call, so no transport comes out. A `hx-post` here
325 /// would be htmx sending the file to the signing endpoint, which answers
326 /// JSON and is not where the bytes go.
327 #[test]
328 fn no_transport_is_emitted_for_a_host_made_call() {
329 let html = super::audio("11111111-1111-1111-1111-111111111111");
330
331 assert!(!html.contains("hx-post"), "{html}");
332 assert!(!html.contains("hx-trigger"), "{html}");
333 assert!(!html.contains("href="), "{html}");
334 }
335
336 /// The payload the chain needs, carried by the description rather than by
337 /// a `data-item-id` attribute on an ancestor. This is the assertion that
338 /// would be silent if it were wrong: the upload would presign against no
339 /// item and fail at the far end.
340 #[test]
341 fn the_payload_rides_with_the_destination() {
342 let html = super::audio("11111111-1111-1111-1111-111111111111");
343
344 assert!(html.contains("data-vals="), "{html}");
345 assert!(html.contains("item_id"), "{html}");
346 assert!(
347 html.contains("11111111-1111-1111-1111-111111111111"),
348 "{html}"
349 );
350 assert!(html.contains("file_type"), "{html}");
351 assert!(!html.contains("hx-vals"), "{html}");
352 }
353
354 /// One list, two surfaces, and it is written once. Both spellings of it
355 /// were in Askama before, eleven lines apart in the same file.
356 #[test]
357 fn both_version_surfaces_take_the_same_files() {
358 let single = super::existing_version("22222222-2222-2222-2222-222222222222");
359 let queue = super::version_queue();
360 let accept = r#"accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3""#;
361
362 assert!(single.contains(accept), "{single}");
363 assert!(queue.contains(accept), "{queue}");
364 }
365
366 /// The version id is in the address, which is where that route puts it.
367 #[test]
368 fn an_existing_version_is_addressed_by_id() {
369 let html = super::existing_version("22222222-2222-2222-2222-222222222222");
370
371 assert!(
372 html.contains(
373 r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""#
374 ),
375 "{html}"
376 );
377 }
378
379 /// Several files, and no destination at all. The queue is uploaded by the
380 /// button that also carries the version number, so a `data-sends` here
381 /// would be a second answer to where the bytes go.
382 #[test]
383 fn the_queue_takes_many_files_and_sends_none_of_them() {
384 let html = super::version_queue();
385
386 assert!(html.contains(" multiple"), "{html}");
387 assert!(!html.contains("data-sends"), "{html}");
388 assert!(!html.contains("data-awaiting"), "{html}");
389 }
390
391 /// The gesture is the host's and needs something to bind to. Without the
392 /// area there is still a working file input, which is the no-script
393 /// rendering rather than a failure, so this is the test that says the
394 /// enhancement has a target.
395 #[test]
396 fn the_drop_area_is_there_for_the_binder() {
397 let html = super::audio("11111111-1111-1111-1111-111111111111");
398
399 assert!(html.contains(r#"class="file-upload-area""#), "{html}");
400 assert!(html.contains("Drop audio file here"), "{html}");
401 }
402
403 /// Three media types, one destination, and the entity riding in the
404 /// payload. The accept list was written out by hand in both wizard steps
405 /// before this, in two files that had no way to disagree loudly.
406 #[test]
407 fn an_image_says_its_three_media_types_and_where_it_goes() {
408 let html = super::image(
409 "/api/items/image/presign",
410 "item_id",
411 "11111111-1111-1111-1111-111111111111",
412 );
413
414 assert!(
415 html.contains(r#"accept="image/jpeg,image/png,image/webp""#),
416 "{html}"
417 );
418 assert!(!html.contains(" multiple"), "{html}");
419 assert!(
420 html.contains(r#"data-sends="/api/items/image/presign""#),
421 "{html}"
422 );
423 assert!(html.contains("item_id"), "{html}");
424 assert!(html.contains("data-awaiting="), "{html}");
425 assert!(!html.contains("hx-post"), "{html}");
426 }
427
428 /// Both wizard steps take the same three types through the same function,
429 /// and differ only in the address and the name of the entity. This is the
430 /// assertion that would have caught the two hand-written accept lists
431 /// drifting apart.
432 #[test]
433 fn both_wizard_images_take_the_same_files() {
434 let item = super::image("/api/items/image/presign", "item_id", "i");
435 let project = super::image("/api/projects/image/presign", "project_id", "p");
436 let accept = r#"accept="image/jpeg,image/png,image/webp""#;
437
438 assert!(item.contains(accept), "{item}");
439 assert!(project.contains(accept), "{project}");
440 assert!(
441 project.contains(r#"data-sends="/api/projects/image/presign""#),
442 "{project}"
443 );
444 assert!(project.contains("project_id"), "{project}");
445 }
446
447 fn nav() -> Vec<crate::templates::StepNavItem> {
448 vec![crate::templates::StepNavItem {
449 name: "basics",
450 label: "Basics",
451 state: "active",
452 }]
453 }
454
455 /// The item wizard step, rendered whole. Beyond the described field this
456 /// asserts the two host attributes the binder needs and that the island it
457 /// replaced is gone: `<mnw-image-uploader>` reached the same markup by
458 /// hardcoded element ids from TypeScript.
459 #[test]
460 fn the_item_wizard_step_is_wired_and_says_where_the_url_lands() {
461 use askama::Template as _;
462
463 let html = crate::templates::WizardItemBasicsTemplate {
464 nav: nav(),
465 project_slug: "a-project".into(),
466 item_id: "11111111-1111-1111-1111-111111111111".into(),
467 title: "A track".into(),
468 description: String::new(),
469 cover_image_url: None,
470 }
471 .render()
472 .expect("render the item basics step");
473
474 assert!(
475 html.contains(r#"data-sends="/api/items/image/presign""#),
476 "{html}"
477 );
478 assert!(
479 html.contains(r##"data-upload-fills="#cover-image-url""##),
480 "{html}"
481 );
482 assert!(html.contains("data-upload-shows"), "{html}");
483 assert!(html.contains("data-upload-empty"), "{html}");
484 assert!(html.contains("data-upload-filled"), "{html}");
485 assert!(!html.contains("mnw-image-uploader"), "{html}");
486 }
487
488 /// The project wizard step has two places the picture goes, the dropzone's
489 /// own preview and the card preview, which is the whole reason
490 /// `data-upload-shows` is a mark on many elements rather than one selector.
491 #[test]
492 fn the_project_wizard_step_shows_the_image_in_two_places() {
493 use askama::Template as _;
494
495 let html = crate::templates::WizardProjectAppearanceTemplate {
496 nav: nav(),
497 slug: "a-project".into(),
498 project_id: "22222222-2222-2222-2222-222222222222".into(),
499 cover_image_url: None,
500 project_title: "A project".into(),
501 }
502 .render()
503 .expect("render the project appearance step");
504
505 assert!(
506 html.contains(r#"data-sends="/api/projects/image/presign""#),
507 "{html}"
508 );
509 assert_eq!(html.matches("data-upload-shows").count(), 2, "{html}");
510 assert!(
511 html.contains(r##"data-upload-fills="#cover-image-url""##),
512 "{html}"
513 );
514 assert!(!html.contains("mnw-image-uploader"), "{html}");
515 }
516
517 /// An image already chosen is rendered as the picture, with the placeholder
518 /// hidden, and the host never has to build either. The `src` is written
519 /// only when there is one: an `img` with an empty `src` refetches the page.
520 #[test]
521 fn an_image_already_there_is_rendered_not_constructed() {
522 use askama::Template as _;
523
524 let html = crate::templates::WizardProjectAppearanceTemplate {
525 nav: nav(),
526 slug: "a-project".into(),
527 project_id: "22222222-2222-2222-2222-222222222222".into(),
528 cover_image_url: Some("https://cdn.example/cover.jpg".into()),
529 project_title: "A project".into(),
530 }
531 .render()
532 .expect("render the project appearance step");
533
534 assert!(
535 html.contains(r#"src="https://cdn.example/cover.jpg""#),
536 "{html}"
537 );
538 assert!(!html.contains(r#"src="""#), "{html}");
539 assert!(html.contains("data-upload-empty hidden"), "{html}");
540 }
541
542 /// An item whose only interesting field is the audio it holds. The rest is
543 /// what `Item` needs to exist, and none of it reaches this markup.
544 fn audio_item(audio_s3_key: Option<String>) -> crate::types::Item {
545 crate::types::Item {
546 id: "11111111-1111-1111-1111-111111111111".into(),
547 title: "A recording".into(),
548 price: "0".into(),
549 price_cents: 0,
550 item_type: "audio".into(),
551 description: String::new(),
552 thumbnail: String::new(),
553 release_date: String::new(),
554 sales_count: 0,
555 tags: Vec::new(),
556 content: crate::types::ItemContent::Audio {
557 duration: None,
558 duration_seconds: None,
559 cover_url: None,
560 episode_number: None,
561 audio_s3_key,
562 },
563 cover_image_url: None,
564 is_free: true,
565 can_access: true,
566 enable_license_keys: false,
567 default_max_activations: None,
568 pwyw_enabled: false,
569 pwyw_min_cents: None,
570 publish_at: None,
571 is_public: true,
572 listed: true,
573 bundle_item_count: 0,
574 license_preset: None,
575 custom_license_text: None,
576 ai_tier: crate::db::AiTier::Handmade,
577 ai_disclosure: None,
578 }
579 }
580
581 /// The details tab, rendered whole. The two call sites are Askama's, so
582 /// one of each is rendered here to say the wiring holds: this one also
583 /// carries the address the binder follows once the bytes have landed,
584 /// which is a host fact and has nowhere else to live.
585 #[test]
586 fn the_audio_call_site_is_wired_and_carries_where_it_goes_after() {
587 use askama::Template as _;
588
589 let html = crate::templates::ItemDetailsTabTemplate {
590 item: audio_item(None),
591 bundle_items: Vec::new(),
592 bundleable_items: Vec::new(),
593 sections: Vec::new(),
594 tag_suggestions: crate::templates::TagSuggestionsTemplate {
595 suggestions: Vec::new(),
596 },
597 }
598 .render()
599 .expect("render the details tab");
600
601 assert!(
602 html.contains(r#"data-sends="/api/upload/presign""#),
603 "{html}"
604 );
605 assert!(
606 html.contains(
607 r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""#
608 ),
609 "{html}"
610 );
611 }
612
613 /// The files tab, rendered whole. Two assertions the JS depends on: the
614 /// queue's input keeps the id `item-upload.js` reads it back by, and a
615 /// version with no file gets a field of its own addressed to that version.
616 #[test]
617 fn the_files_call_sites_keep_what_the_host_reads_them_by() {
618 use askama::Template as _;
619
620 let version = crate::types::Version {
621 id: "22222222-2222-2222-2222-222222222222".into(),
622 number: "1.0".into(),
623 uploaded_date: "2026-08-20".into(),
624 file_count: 0,
625 size: "0 B".into(),
626 downloads: 0,
627 status: "draft".into(),
628 is_current: true,
629 has_file: false,
630 file_name: None,
631 label: None,
632 };
633
634 let html = crate::templates::ItemVersionUploadTemplate {
635 item: audio_item(None),
636 versions: vec![version],
637 }
638 .render()
639 .expect("render the uploader");
640
641 assert!(html.contains(r#"id="version-files""#), "{html}");
642 assert!(
643 html.contains(r#"id="existing-version-upload-22222222-2222-2222-2222-222222222222""#),
644 "{html}"
645 );
646 assert!(
647 html.contains(
648 r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""#
649 ),
650 "{html}"
651 );
652 }
653
654 /// The two names `item-upload.js` reads the new version's own facts back
655 /// by. A field's name is its id in this renderer, so renaming either of
656 /// these renames the id, and the script would silently read an empty
657 /// version number and post one with no name.
658 #[test]
659 fn the_new_version_questions_keep_the_names_the_host_reads() {
660 let html = super::version_details();
661
662 assert!(html.contains(r#"id="new-version-number""#), "{html}");
663 assert!(html.contains(r#"name="new-version-number""#), "{html}");
664 assert!(html.contains(r#"id="version-changelog""#), "{html}");
665 assert!(html.contains(r#"placeholder="e.g., 1.0""#), "{html}");
666 }
667
668 /// Where the run starts and who makes it. `data-sends` is the whole of
669 /// what the script needs: it used to build this address out of a
670 /// `data-item-id` attribute, which is one route written down twice.
671 #[test]
672 fn upload_all_carries_its_address_and_no_transport() {
673 let html = super::version_upload_all("11111111-1111-1111-1111-111111111111");
674
675 assert!(html.contains("<button"), "{html}");
676 assert!(html.contains("data-act"), "{html}");
677 assert!(
678 html.contains(
679 r#"data-sends="/api/items/11111111-1111-1111-1111-111111111111/versions""#
680 ),
681 "{html}"
682 );
683 assert!(html.contains("Upload All"), "{html}");
684 assert!(!html.contains("hx-post"), "{html}");
685 }
686
687 /// One label per question. The template wrote a second `Files` label above
688 /// the picker and a hint beside it, both of which the field already owns.
689 #[test]
690 fn the_queue_owns_its_label_and_its_hint() {
691 let html = super::version_queue();
692
693 assert_eq!(html.matches(">Files<").count(), 1, "{html}");
694 assert!(html.contains("Add one file per platform"), "{html}");
695 }
696
697 /// The uploader surface, rendered whole: exactly one control the script
698 /// can find by the address it carries, so `button[data-act][data-sends]`
699 /// is unambiguous, and the landing address is on the wrapper.
700 #[test]
701 fn the_uploader_surface_names_one_control_and_where_it_lands() {
702 use askama::Template as _;
703
704 let html = crate::templates::ItemVersionUploadTemplate {
705 item: audio_item(None),
706 versions: Vec::new(),
707 }
708 .render()
709 .expect("render the uploader");
710
711 // Three on the surface, plus the Remove inside the queue's blank
712 // `<template>`: inert markup until the picker clones it into a slot,
713 // and the described queue's own rather than a fourth control anyone
714 // wrote here. The queue offers no add control at all, which is the
715 // point of `Adds::Elsewhere`.
716 assert_eq!(html.matches("<button").count(), 4, "{html}");
717 assert!(!html.contains("field-repeat-add"), "{html}");
718 assert!(
719 html.contains(
720 r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""#
721 ),
722 "{html}"
723 );
724 assert!(!html.contains("data-item-id"), "{html}");
725 }
726 }
727