Public: anyone can read this; the crew can change it.

Get a 30-second video tour script for a Reel or TikTok to approve, shot by shot from the listing's photos and features #118

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/116-get-a-30-second-video-tour-script-for-a → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/116-get-a-30-second-video-tour-script-for-a was merged into main as 7843fea.

Review

Sign in to review this change.
24 files changed +344 −72
README.md +10 −7
@@ -23,11 +23,11 @@ to make a listing of your own.
2323 is only to read; the new listing is yours to change.
2424 4. **Approve one.** On your listing, read the MLS description, then press
2525 **Approve** under it (**Approve with 1 flag** when the check still has
26 something to say). The top of the page now says "1 of 7 approved".
26 something to say). The top of the page now says "1 of 8 approved".
2727 5. **Change the tone.** Pick another tone beside the address (Classic, Warm
2828 or Bold) and press **Rewrite all**. Every asset is written again in that
2929 tone, your edits are replaced, and the approval is cleared: it says
30 "0 of 7 approved" again, because you have not read the new words yet.
30 "0 of 8 approved" again, because you have not read the new words yet.
3131 6. **Put back your MLS words.** Under the MLS box, press **Earlier versions
3232 (1)**: it lists the words the rewrite replaced, when, by whom and why
3333 ("rewrite in Bold"), and the first line of each. Press **Restore** by
@@ -44,7 +44,7 @@ to make a listing of your own.
4444 square feet, lot, year, price, features and brand. Lower the price and
4545 press **Save and write all again**: the new price is on the flyer, every
4646 asset is written again from the facts (your edits are replaced), and the
47 approval is cleared, so it says "0 of 7 approved". The photos stay.
47 approval is cleared, so it says "0 of 8 approved". The photos stay.
4848 8. **Add an open house.** Press **Edit facts** again. Under "Open house"
4949 pick a date, a start and an end time (Alder Lane's sample already has one),
5050 then press **Save and write all again**. The flyer gets a band under the
@@ -79,7 +79,7 @@ to make a listing of your own.
7979 times exactly as you typed them, with your contact, and the property page
8080 link in the text of both. With two open houses the subject names both and
8181 claims no "tomorrow". Like every asset they are unapproved drafts that count
82 in "0 of 9 approved", keep versions, are Fair Housing checked, are rewritten
82 in "0 of 10 approved", keep versions, are Fair Housing checked, are rewritten
8383 (and unapproved) when the facts change, and go when the open house does or
8484 when the listing is sold or leaves the market.
8585 **Copy a post.** Once you approve a social post, a **Copy post** button
@@ -98,7 +98,7 @@ to make a listing of your own.
9898 status**. Two more assets appear, a status post under Social and a status
9999 email under Email ("Just sold — 9 Walkthrough Way"), written in the
100100 listing's tone and checked like the rest; each needs your approval, so it
101 says "0 of 9 approved". The other assets and your approvals stay. The
101 says "0 of 10 approved". The other assets and your approvals stay. The
102102 flyer wears a "Sold" band, and the list on the first screen shows the
103103 status beside the approval count. *88 Meridian Street* is already sold, so
104104 you can see it. **Under contract** does the same with its own words, and
@@ -239,7 +239,10 @@ list cannot drift from the app.
239239 3. **Read every word.** The listing opens at its own address
240240 (`/listings/<number>`, bookmark it) with the assets in tabs: the **MLS
241241 description**, **three social posts** (short, medium, carousel
242 captions), the **flyer** and the **email blast**. Each has an edit box, a
242 captions), the **flyer**, the **email blast** and a **30-second video tour
243 script** (6–8 numbered shots, each naming the photo to show, the words to
244 say and the on-screen caption, at most 80 spoken words, counted beside it
245 like the MLS length). Each has an edit box, a
243246 **Copy** button, and the **Fair Housing check** beside it: green when
244247 clear, amber with the risky words underlined and a one-line reason —
245248 *"'family-friendly' implies familial status; say 'near parks and
@@ -451,7 +454,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
451454 | ---------------------------------- | -------------------------------------------------------------- |
452455 | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail |
453456 | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived |
454 | `src/generate.ts` | `generate(listing)`: the six assets from the facts (more once it is under contract, sold or lowered in price), in three tones |
457 | `src/generate.ts` | `generate(listing)`: the seven assets from the facts, the video tour script among them (more once it is under contract, sold or lowered in price), in three tones |
455458 | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count |
456459 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
457460 | `src/photos.ts` | photos as files beside the database, known by their bytes |
contract/openapi.json +3
@@ -648,6 +648,7 @@
648648 "social_price",
649649 "flyer",
650650 "email",
651 "video_script",
651652 "email_status",
652653 "email_price",
653654 "email_followup",
@@ -1539,6 +1540,7 @@
15391540 "social_price",
15401541 "flyer",
15411542 "email",
1543 "video_script",
15421544 "email_status",
15431545 "email_price",
15441546 "email_followup",
@@ -1892,6 +1894,7 @@
18921894 "social_price",
18931895 "flyer",
18941896 "email",
1897 "video_script",
18951898 "email_status",
18961899 "email_price",
18971900 "email_followup",
src/actions.ts +42
@@ -445,6 +445,22 @@ export function draftMissingUpdates(db: Database): number[] {
445445 return ids;

Related: the photo-edit path (PHOTO_ASSETS ~line 1207) never touches video_script. Redraft it there and clear its approval when photos or their order change.

446446 }
447447
448 /** A listing launched before the video tour script existed gets its draft, once, and keeps every other approval; the ids drafted. */
449 export function draftMissingVideoScripts(db: Database): number[] {
450 const ids = db
451 .query<{ id: number }, []>(
452 "SELECT id FROM listing WHERE id NOT IN (SELECT listing_id FROM asset WHERE kind = 'video_script')",
453 )
454 .all()
455 .map((r) => r.id);
456 for (const id of ids) {
457 const l = launched(db, id)!;
458 const words = generate({ facts: { ...l, back_on_market: false }, agent: agentOf(l), link: propertyPath(l, publicOrigin) });
459 db.query("INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, 'video_script', ?2, 0)").run(id, words.video_script);
460 }
461 return ids;
462 }
463
448464 /** How many earlier versions of one asset's words are kept. */
449465 export const VERSIONS_KEPT = 5;
450466
@@ -1206,6 +1222,31 @@ function changeStatus(
12061222 /** The assets that show the photos: what they show changed, so their approval goes. */
12071223 const PHOTO_ASSETS = ["flyer", "email"] as const;
12081224
1225 /**
1226 * The video script names photos by number and description, so it follows them: a script nobody
1227 * edited is written again from the new photos, and loses its approval if its words changed or the photos were reordered or removed. One a
1228 * person edited keeps their words, and loses its approval when the photos were reordered or removed.
1229 */
1230 function redraftVideoScript(db: Database, listing: Listing, reshown: boolean): void {
1231 const row = db
1232 .query<{ body: string; edited: number }, [number]>("SELECT body, edited FROM asset WHERE listing_id = ?1 AND kind = 'video_script'")
1233 .get(listing.id);
1234 if (row === null) return;
1235 const clear = "approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL";
1236 if (row.edited === 1) {
1237 if (reshown) db.query(`UPDATE asset SET ${clear} WHERE listing_id = ?1 AND kind = 'video_script'`).run(listing.id);
1238 return;
1239 }
1240 const body = generate({ facts: { ...listing, back_on_market: false }, agent: agentOf(listing), link: propertyPath(listing, publicOrigin) }).video_script;
1241 if (body === row.body) {
1242 if (reshown) db.query(`UPDATE asset SET ${clear} WHERE listing_id = ?1 AND kind = 'video_script'`).run(listing.id);
1243 return;
1244 }
1245 db.query(
1246 `UPDATE asset SET body = ?2, ${clear}, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?1 AND kind = 'video_script'`,
1247 ).run(listing.id, body);
1248 }
1249
12091250 /**
12101251 * Add, remove and reorder a launched listing's photos; the first is the hero.
12111252 * `order` names the photos that exist now, first to last (any it leaves out
@@ -1300,6 +1341,7 @@ function changePhotos(
13001341 `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL
13011342 WHERE listing_id = ?1 AND kind = ?2`,
13021343 ).run(now.id, kind);
1344 redraftVideoScript(db, { ...now, photos, photo_alt: alt }, reshown);
13031345 if (reshown) redraftUpdate(db, now.id);
13041346 for (const f of files) writePhoto(db, f.name, f.bytes);
13051347 })();
src/backonmarket.test.ts +1 −1
@@ -86,7 +86,7 @@ test("sold → active, and coming soon → active, take the status words away",
8686 const { db, id } = launch();
8787 setStatus(db, me, { id, status: from });
8888 setStatus(db, me, { id, status: "active" });
89 expect(get(db, id).assets).toHaveLength(7);
89 expect(get(db, id).assets).toHaveLength(8);
9090 }
9191 });
9292
src/comingsoon.test.ts +4 −4
@@ -46,7 +46,7 @@ test("coming soon is offered first, and the teaser says the facts only, in each
4646 const kinds = soon.value.assets.map((a) => a.kind);
4747 expect(kinds).toContain("social_status");
4848 expect(kinds).toContain("email_status");
49 expect(soon.value.assets).toHaveLength(9);
49 expect(soon.value.assets).toHaveLength(10);
5050 for (const a of soon.value.assets.filter((a) => a.kind.endsWith("_status"))) {
5151 expect(a.text).toMatch(/coming soon/i);
5252 expect(a.text).toContain("412 Alder Lane");
@@ -66,14 +66,14 @@ test("each is approved on its own, core approvals stay, and Active or another st
6666 const soon = setStatus(db, me, { id, status: "coming_soon" });
6767 expect(soon.ok && soon.value.assets.filter((a) => a.approval !== null).map((a) => a.kind)).toEqual(["mls"]);
6868 const summary = listListings(db, me);
69 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(9);
69 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(10);
7070
7171 const sold = setStatus(db, me, { id, status: "sold" });
7272 expect(sold.ok && sold.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Just sold");
7373 const again = setStatus(db, me, { id, status: "coming_soon" });
7474 expect(again.ok && again.value.assets.find((a) => a.kind === "social_status")!.text).toContain("Coming soon");
7575 const back = setStatus(db, me, { id, status: "active" });
76 expect(back.ok && back.value.assets).toHaveLength(7);
76 expect(back.ok && back.value.assets).toHaveLength(8);
7777 expect(back.ok && back.value.assets.filter((a) => a.approval !== null)).toHaveLength(1);
7878 expect(setStatus(db, me, { id, status: "coming-soon" })).toMatchObject({ ok: false, error: "invalid" });
7979 });
@@ -111,7 +111,7 @@ test("the web and API doors accept it and refuse the unknown with a 422", async
111111 const patch = (body: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify(body) });
112112 const ok = await patch({ status: "coming_soon" });
113113 expect(ok.status).toBe(200);
114 expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(9);
114 expect(((await ok.json()) as { assets: unknown[] }).assets).toHaveLength(10);
115115 expect((await patch({ status: "pending" })).status).toBe(422);
116116
117117 const post = (status: string) => {
src/generate.test.ts +51 −2
@@ -1,5 +1,5 @@
11 /**
2 * Generation: all five assets from the facts alone, in each tone, carrying
2 * Generation: all the core assets from the facts alone, in each tone, carrying
33 * what advertising rules ask for (the brokerage, the license number) and
44 * nothing the Fair Housing check would flag.
55 */
@@ -11,6 +11,8 @@ import {
1111 flyerParts,
1212 generate,
1313 CORE_KINDS,
14 SPOKEN_MAX,
15 spokenWords,
1416 lotOf,
1517 street,
1618 TONES,
@@ -22,7 +24,7 @@ import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts";
2224 const agent = agentOf(SAMPLE);
2325 const facts = (tone: Facts["tone"]): Facts => ({ ...SAMPLE, tone });
2426
25 test("all five assets, every text filled, in every tone", () => {
27 test("all the core assets, every text filled, in every tone", () => {
2628 for (const tone of TONES) {
2729 const words = generate({ facts: facts(tone), agent });
2830 expect(Object.keys(words).sort()).toEqual([...CORE_KINDS].sort());
@@ -125,3 +127,50 @@ test("singular for one, the article fits, a lot that is not a measurement is lef
125127 }
126128 expect(factItems({ ...condo, lot: "0.18 acre" }).map((i) => i.label)).toContain("lot");
127129 });
130
131 const script = (tone: Facts["tone"], extra: Partial<Facts> = {}): string =>
132 generate({ facts: { ...facts(tone), photos: ["a.jpg", "b.jpg", "c.jpg"], photo_alt: { "a.jpg": "Front porch", "b.jpg": "Kitchen island" }, ...extra }, agent, link: "https://x.test/homes/1" }).video_script;
133
134 test("the video script is 6 to 8 shots in every tone, with a hook first and a call to act last", () => {
135 for (const tone of TONES) {
136 const text = script(tone, { open_houses: [{ date: "2027-03-07", start: "13:00", end: "15:00" }] });
137 const shots = text.split("\n\n");
138 expect(shots.length).toBeGreaterThanOrEqual(6);
139 expect(shots.length).toBeLessThanOrEqual(8);
140 shots.forEach((s, i) => {
141 expect(s).toStartWith(`Shot ${i + 1}/${shots.length} · `);
142 expect(s).toMatch(/\nSay: ".+"\nCaption: .+/);
143 });
144 expect(shots[0]).toContain("412 Alder Lane");
145 expect(shots.at(-1)).toContain("Property page: https://x.test/homes/1");
146 expect(shots.at(-1)).toContain("Sunday, March 7, 1–3 pm");
147 expect(check(text).clear).toBe(true);
148 }
149 });
150
151 test("the spoken words fit 30 seconds, even for long features", () => {
152 for (const tone of TONES) {
153 expect(spokenWords(script(tone))).toBeLessThanOrEqual(SPOKEN_MAX);
154 const long = SAMPLE.features.map((f) => `${f} and then a great many more words to say about it, which goes on and on`);
155 const text = script(tone, { features: long });
156 expect(spokenWords(text)).toBeLessThanOrEqual(SPOKEN_MAX);
157 expect(text.split("\n\n").length).toBeGreaterThanOrEqual(6);
158 }
159 });
160
161 test("the video script names only photos the listing has, by description where there is one", () => {
162 const text = script("classic");
163 const named = [...text.matchAll(/Show photo (\d+)/g)].map((m) => Number(m[1]));
164 expect(named.length).toBeGreaterThan(0);
165 for (const n of named) expect(n).toBeGreaterThanOrEqual(1), expect(n).toBeLessThanOrEqual(3);
166 expect(text).toContain("Show photo 1 (Front porch)");
167 expect(text).toContain("Show photo 2 (Kitchen island)");
168 expect(text).toContain("Show photo 3\n");
169 const none = script("classic", { photos: [] });
170 expect(none).not.toContain("Show photo");
171 expect(none).toContain("Film on location");
172 });
173
174 test("a flagged phrase in the video script is caught", () => {
175 expect(check(`${script("warm")}\nSay: "Perfect for young families."`).clear).toBe(false);
176 });
src/generate.ts +77 −1
@@ -14,7 +14,7 @@ export const TONES = ["classic", "warm", "bold"] as const;
1414 export type Tone = (typeof TONES)[number];
1515
1616 /** Every asset a listing always has; the status ones come and go with its status. */
17 export const CORE_KINDS = ["mls", "social_short", "social_medium", "social_carousel", "flyer", "email"] as const;
17 export const CORE_KINDS = ["mls", "social_short", "social_medium", "social_carousel", "flyer", "email", "video_script"] as const;
1818
1919 export const KINDS = [
2020 "mls",
@@ -25,6 +25,7 @@ export const KINDS = [
2525 "social_price",
2626 "flyer",
2727 "email",
28 "video_script",
2829 "email_status",
2930 "email_price",
3031 "email_followup",
@@ -43,6 +44,7 @@ export const LABELS: Readonly<Record<Kind, string>> = {
4344 social_status: "Status post",
4445 flyer: "Flyer copy",
4546 email: "Email blast",
47 video_script: "Video tour script",
4648 email_status: "Status email",
4749 social_price: "Price improvement post",
4850 email_price: "Price improvement email",
@@ -114,6 +116,10 @@ export interface Facts {
114116 readonly back_on_market?: boolean;
115117 /** Whole dollars: the price before it was lowered; absent or null when it has not been. */
116118 readonly was_price?: number | null;
119 /** The listing's photo names, in order: the video script says which to film or show. */
120 readonly photos?: readonly string[];
121 /** What each photo shows, by photo name, where the agent described it. */
122 readonly photo_alt?: Readonly<Record<string, string>>;
117123 }
118124
119125 export interface Agent {
@@ -370,6 +376,75 @@ function email(f: Facts, a: Agent): string {
370376 return `Subject: ${subject}\n\n${when === null ? "" : `${when}\n\n`}${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`;
371377 }
372378
379 /** The most words the video script has the agent say: about 30 seconds on camera. */
380 export const SPOKEN_MAX = 80;
381
382 /** The words said on camera in a video script: what is inside its `Say: "…"` lines. */
383 export const spokenWords = (text: string): number =>
384 text
385 .split("\n")
386 .filter((l) => l.startsWith("Say: "))
387 .reduce((n, l) => n + l.slice(5).replace(/["“”]/g, "").split(/\s+/).filter(Boolean).length, 0);
388
389 /** A feature as one short phrase to say aloud: its first clause, at most `max` words. */
390 const spoken = (line: string, max: number): string =>
391 bare(line).split(/,| with | and | featuring /)[0]!.trim().split(" ").slice(0, max).join(" ");
392
393 interface Shot {
394 readonly say: string;
395 readonly caption: string;
396 }
397
398 /**
399 * The 30-second video tour: numbered shots, each naming the photo to film or
400 * show, the words to say and the caption on screen. A hook opens it and a call
401 * to act closes it; the spoken words stay within SPOKEN_MAX.
402 */
403 function videoScript(f: Facts, link?: string): string {
404 const where = street(f.address);
405 const open = openHouseLine(f);
406 const hook: Shot = {
407 say: {
408 classic: `Welcome to ${where}, offered at ${money(f.price)}.`,
409 warm: `Come inside ${where} with me.`,
410 bold: `${where}. ${money(f.price)}. Wait until you see inside.`,
411 }[f.tone],
412 caption: `Just listed: ${where}`,
413 };
414 const facts: Shot = {
415 say: `${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet, built in ${f.year}.`,
416 caption: factsLine(f),
417 };
418 const act: Shot = {
419 say: `${{
420 classic: "Message me to book a private showing.",
421 warm: "Message me and I will show you around.",
422 bold: "Book your showing today.",
423 }[f.tone]}${open === null ? "" : ` ${open}`}`,
424 caption: `${link === undefined ? "Link in bio" : `Property page: ${link}`}${open === null ? "" : ` · ${open}`}`,
425 };
426 const build = (features: number, words: number): Shot[] => [
427 hook,
428 facts,
429 ...f.features.slice(0, features).map((line) => ({ say: `${sentence(spoken(line, words))}`, caption: bare(line) })),
430 act,
431 ];
432 let shots = build(4, 7);
433 for (const [n, w] of [[4, 5], [3, 5], [3, 3], [2, 3]] as const) {
434 if (shots.length >= 6 && shots.reduce((t, s) => t + spokenWords(`Say: ${s.say}`), 0) <= SPOKEN_MAX) break;
435 shots = build(n, w);
436 }
437 const photos = f.photos ?? [];
438 return shots
439 .map((s, i) => {
440 const name = photos.length === 0 ? undefined : photos[i % photos.length]!;
441 const alt = name === undefined ? undefined : f.photo_alt?.[name];
442 const film = name === undefined ? "Film on location" : `Show photo ${(i % photos.length) + 1}${alt === undefined || alt === "" ? "" : ` (${alt})`}`;
443 return `Shot ${i + 1}/${shots.length} · ${film}\nSay: "${s.say}"\nCaption: ${s.caption}`;
444 })
445 .join("\n\n");
446 }
447
373448 /** What a teaser says of the home: the price, then beds · baths · square feet. Never a date. */
374449 const teaserFacts = (f: Facts): string =>
375450 `${money(f.price)} · ${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} square feet`;
@@ -605,6 +680,7 @@ export function generate(listing: {
605680 social_carousel: socialCarousel(f, a),
606681 flyer: flyer(f),
607682 email: email(f, a),
683 video_script: videoScript(f, link),
608684 ...(announced ? { social_status: statusPost(f, a), email_status: statusEmail(f, a) } : {}),
609685 ...(cut ? { social_price: pricePost(f, a), email_price: priceEmail(f, a) } : {}),
610686 ...(openHouseLine(f) === null ? {} : { email_followup: followUpEmail(f, a) }),
src/listings.test.ts +8 −8
@@ -444,7 +444,7 @@ test("the page says how many are approved, names the flags, and the form and API
444444 const { to } = await launch(alice);
445445 const id = Number(to.split("/").at(-1));
446446 let page = await (await fetch(`${base}/listings/${id}`)).text();
447 expect(page).toContain("0 of 7 approved");
447 expect(page).toContain("0 of 8 approved");
448448 expect(page).not.toContain("Approve</button>"); // anonymous readers have no button
449449 // Two flagged phrases make the button say so.
450450 const edit = new FormData();
@@ -462,12 +462,12 @@ test("the page says how many are approved, names the flags, and the form and API
462462 form.set("text", "unsaved typing");
463463 const stale = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
464464 expect(stale.status).toBe(303);
465 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 7 approved");
465 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("0 of 8 approved");
466466 form.delete("text");
467467 const posted = await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: form, headers: alice, redirect: "manual" });
468468 expect(posted.status).toBe(303);
469469 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
470 expect(page).toContain("1 of 7 approved");
470 expect(page).toContain("1 of 8 approved");
471471 expect(page).toContain("Approved by carol");
472472 expect(page).toContain("via the page");
473473
@@ -489,8 +489,8 @@ test("the page says how many are approved, names the flags, and the form and API
489489 expect(scripted.status).toBeGreaterThanOrEqual(400);
490490 expect(JSON.stringify(await scripted.json())).toContain("person_required");
491491 expect(await formApprove("email", "2000-01-01T00:00:00.000Z")).toBe(303);
492 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("1 of 7 approved");
493 for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email"])
492 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("1 of 8 approved");
493 for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email", "video_script"])
494494 expect(await formApprove(kind, at(kind))).toBe(303);
495495 // The seller update reports those approvals, so its words (and `updated_at`) moved with them: read it again.
496496 const moved = (await (await fetch(`${base}/api/listings/${id}`, { headers: alice })).json()) as { assets: { kind: string; updated_at: string }[] };
@@ -538,7 +538,7 @@ test("the home list says what is approved, and flyer and email wear a draft band
538538 const id = Number(to.split("/").at(-1));
539539 const card = (html: string) => html.slice(html.indexOf(`href="/listings/${id}"`)).split("</li>")[0]!;
540540 const home = async () => card(await (await fetch(`${base}/`)).text());
541 expect(await home()).toContain("0 of 7 approved");
541 expect(await home()).toContain("0 of 8 approved");
542542 for (const path of ["flyer", "email"])
543543 expect(await (await fetch(`${base}${to}/${path}`)).text()).toContain("Draft — not approved");
544544 expect(await (await fetch(`${base}${to}`)).text()).toContain("Draft — not approved");
@@ -553,10 +553,10 @@ test("the home list says what is approved, and flyer and email wear a draft band
553553 for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(303);
554554 for (const path of ["flyer", "email"])
555555 expect(await (await fetch(`${base}${to}/${path}`)).text()).not.toContain("Draft — not approved");
556 expect(await home()).toContain("2 of 7 approved");
556 expect(await home()).toContain("2 of 8 approved");
557557
558558 const summary = ((await (await fetch(`${base}/api/listings`)).json()) as { id: number; approvals: { kind: string; approved: boolean }[] }[]).find((l) => l.id === id)!;
559 expect(summary.approvals).toHaveLength(7);
559 expect(summary.approvals).toHaveLength(8);
560560 expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]);
561561
562562 for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email" && a.kind !== "email_update") expect((await approve(a.kind)).status).toBe(303);
src/offmarket.test.ts +1 −1
@@ -150,7 +150,7 @@ test("existing rows keep their statuses and assets across the migration", () =>
150150 const again = openDb(dir);
151151 const l = get(again, made.value.id);
152152 expect(l).toMatchObject({ status: "sold", sold_price: 700000 });
153 expect(l.assets.length).toBe(9);
153 expect(l.assets.length).toBe(10);
154154 });
155155
156156 test("a person's edited flyer, email and post lose their open house line but keep their other words", () => {
src/openhouse.test.ts +3 −3
@@ -94,7 +94,7 @@ test("adding, changing and clearing it writes the assets again and clears approv
9494 return again.ok ? again.value.assets.filter((a) => a.approval !== null).length : -1;
9595 };
9696 expect(text("flyer")).not.toContain("Open ");
97 expect(approve()).toBe(6);
97 expect(approve()).toBe(7);
9898
9999 const set = updateFacts(db, me, { id, fields: { open_houses: [sunday] } });
100100 expect(set).toMatchObject({ ok: true, value: { open_houses: [sunday] } });
@@ -103,7 +103,7 @@ test("adding, changing and clearing it writes the assets again and clears approv
103103 if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true);
104104
105105 // A change to something else keeps it.
106 expect(approve()).toBe(9);
106 expect(approve()).toBe(10);
107107 expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } });
108108
109109 const moved = updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, date: "2027-03-14" }] } });
@@ -111,7 +111,7 @@ test("adding, changing and clearing it writes the assets again and clears approv
111111 expect(text("mls")).toContain("Sunday, March 14, 1–3 pm");
112112 expect(text("mls")).not.toContain("March 7");
113113
114 expect(approve()).toBe(11);
114 expect(approve()).toBe(12);
115115 const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } });
116116 if (!cleared.ok) throw new Error(cleared.message);
117117 expect(cleared.value.open_houses).toEqual([]);
src/pages/listing.ts +1
@@ -229,6 +229,7 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] =
229229 { id: "mls", label: "MLS", kinds: ["mls"] },
230230 { id: "social", label: "Social", kinds: ["social_short", "social_medium", "social_carousel", "social_status", "social_price", "social_reminder"] },
231231 { id: "flyer", label: "Flyer", kinds: ["flyer"] },
232 { id: "video", label: "Video", kinds: ["video_script"] },
232233 { id: "email", label: "Email", kinds: ["email", "email_status", "email_price", "email_followup", "email_reminder"] },
233234 { id: "update", label: "Seller update", kinds: ["email_update"] },
234235 ];
src/photoalt.test.ts +3 −3
@@ -89,7 +89,7 @@ test("a flagged description shows its finding beside the box", () => {
8989 expect(factsPage({ who: pat, listing: load() })).not.toContain("Phrases to review in this description");
9090 });
9191
92 test("a description clears no approval; a new order does", () => {
92 test("a description clears only the video script's approval, which names it; a new order clears the flyer and the email too", () => {
9393 const approve = () =>
9494 db.query("UPDATE asset SET approved_by_id = 'x', approved_by_name = 'x', approved_at = 'now', approved_via = 'x' WHERE listing_id = ?1").run(id);
9595 const approved = () =>
@@ -97,9 +97,9 @@ test("a description clears no approval; a new order does", () => {
9797 approve();
9898 const all = approved();
9999 updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Garden at dusk" } });
100 expect(approved()).toBe(all);
100 expect(approved()).toBe(all - 1);
101101 updatePhotos(db, pat, { id, order: ["sample-garden.svg"] });
102 expect(approved()).toBe(all - 2);
102 expect(approved()).toBe(all - 3);
103103 });
104104
105105 test("removing a photo removes its description; empty text takes one away", () => {
src/postlength.test.ts +5
@@ -71,3 +71,8 @@ test("the three sample listings' MLS descriptions stay under the limit in every
7171 expect([sample.address, tone, overMls("mls", mls)]).toEqual([sample.address, tone, 0]);
7272 }
7373 });
74
75 test("the video script's count is its spoken words out of 80", async () => {
76 const { countLine } = await import("./postlength.ts");
77 expect(countLine("video_script", 'Shot 1/6 · Film on location\nSay: "One two three."\nCaption: ignored words here')).toBe("3 / 80 spoken words");
78 });
src/postlength.ts +3
@@ -5,6 +5,8 @@
55 * weights (wide CJK characters, for one) are not modelled.
66 */
77
8 import { SPOKEN_MAX, spokenWords } from "./generate.ts";
9
810 export const LIMITS = { x: 280, instagram: 2200, facebook: 63206 } as const;
911 /** The common MLS public-remarks cap: what an MLS keeps of the description, counted in code points. */
1012 export const MLS_LIMIT = 1000;
@@ -108,5 +110,6 @@ export const overOutcome = (kind: string, text: string): string =>
108110 export function countLine(kind: string, text: string): string | null {
109111 if (isPost(kind)) return lengthLine(text);
110112 if (isMls(kind)) return `${points(text).toLocaleString("en-US")} / ${MLS_LIMIT.toLocaleString("en-US")} characters`;
113 if (kind === "video_script") return `${spokenWords(text)} / ${SPOKEN_MAX} spoken words`;
111114 return null;
112115 }
src/priceimprovement.test.ts +1 −1
@@ -124,7 +124,7 @@ test("the terminal lowers the price with r and shows the new assets", () => {
124124 expect(now.price).toBe(849000);
125125 expect(now.was_price).toBe(899000);
126126 expect(now.assets.map((a) => a.kind)).toContain("social_price");
127 expect(plain(tty.screen())).toContain("0 of 9 approved");
127 expect(plain(tty.screen())).toContain("0 of 10 approved");
128128 tty.press("r");
129129 for (let i = 0; i < 10; i++) tty.press("\x7f");
130130 for (const key of "lots") tty.press(key);
src/reminder.test.ts +2 −2
@@ -71,8 +71,8 @@ test("a listing with an open house gets both, unapproved, counted, with versions
7171 const l = read(db, id);
7272 expect(kinds(db, id)).toEqual(["social_reminder", "email_reminder"]);
7373 for (const kind of ["social_reminder", "email_reminder"]) expect(l.assets.find((a) => a.kind === kind)!.approval).toBeNull();
74 expect(l.assets.length).toBe(10);
75 expect(kit(l)).toContain("0 of 10 assets approved");
74 expect(l.assets.length).toBe(11);
75 expect(kit(l)).toContain("0 of 11 assets approved");
7676 expect(kinds(...(Object.values(launch({ open_houses: [] })) as [ReturnType<typeof openDb>, number]))).toEqual([]);
7777 });
7878
src/report.test.ts +2 −2
@@ -27,8 +27,8 @@ test("the Alder Lane sample reports seven approved rows by its sample agent", as
2727 const r = await call(`/listings/${id}/report`);
2828 expect(r.status).toBe(200);
2929 const html = await r.text();
30 expect(rows(html)).toBe(9);
31 expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(9);
30 expect(rows(html)).toBe(10);
31 expect(html.match(/Approved by [^<]*\(sample\) on \d{4}-\d{2}-\d{2}/g)?.length).toBe(10);
3232 expect(html).not.toContain("Draft — not approved yet");
3333 expect(html).toContain(`/homes/${id}`);
3434 expect(html).toContain("412 Alder Lane");
src/sample.test.ts +17 −2
@@ -58,11 +58,11 @@ const approvedOf = (db: ReturnType<typeof fresh>, like: string) => {
5858 return r.value.assets.filter((a) => a.approval !== null);
5959 };
6060
61 test("Alder Lane opens with all ten assets approved by its sample agent; Birchwood has none", () => {
61 test("Alder Lane opens with all eleven assets approved by its sample agent; Birchwood has none", () => {
6262 const db = fresh();
6363 seed(db);
6464 const alder = approvedOf(db, "412 Alder%");
65 expect(alder).toHaveLength(10);
65 expect(alder).toHaveLength(11);
6666 for (const a of alder) expect(a.approval).toMatchObject({ by_name: "Morgan Reyes (sample)", via: "sample" });
6767 expect(approvedOf(db, "27 Birchwood%")).toHaveLength(0);
6868 expect(approvedOf(db, "88 Meridian%")).toHaveLength(0);
@@ -103,3 +103,18 @@ test("a copy started from the sample starts at 0 approved", () => {
103103 expect(made.ok).toBe(true);
104104 expect(made.ok && made.value.assets.filter((a) => a.approval !== null)).toHaveLength(0);
105105 });
106
107 test("listings that predate the video script get it on boot, and keep every other approval", () => {
108 const db = fresh();
109 seed(db);
110 const approved = () => (db.query("SELECT count(*) AS n FROM asset WHERE approved_at IS NOT NULL AND kind != 'video_script'").get() as { n: number }).n;
111 const before = approved();
112 db.run("DELETE FROM asset WHERE kind = 'video_script'");
113 expect((db.query("SELECT count(*) AS n FROM asset WHERE kind = 'video_script'").get() as { n: number }).n).toBe(0);
114 seed(db);
115 const rows = db.query("SELECT a.approved_via AS via, l.address AS address FROM asset a JOIN listing l ON l.id = a.listing_id WHERE a.kind = 'video_script'").all() as { via: string | null; address: string }[];
116 expect(rows).toHaveLength(3);
117 expect(rows.find((r) => r.address.startsWith("412 Alder"))!.via).toBe("sample");
118 expect(rows.filter((r) => r.via === null)).toHaveLength(2);
119 expect(approved()).toBe(before);
120 });
src/sample.ts +15 −1
@@ -6,7 +6,7 @@
66 */
77
88 import type { Database } from "bun:sqlite";
9 import { draftMissingUpdates, insertListing, markStatus, redraftUpdate } from "./actions.ts";
9 import { draftMissingUpdates, draftMissingVideoScripts, insertListing, markStatus, redraftUpdate } from "./actions.ts";
1010 import type { ListingInput } from "./listing.ts";
1111 import { dayOf, type Interest } from "./showings.ts";
1212
@@ -183,6 +183,20 @@ export function seed(db: Database): void {
183183 // Alder Lane opens finished: every asset signed off by its sample agent, never by a person's door.
184184 if (s.name === "sample") signOff(db, made.id, `${s.listing.agent_name} (sample)`);
185185 }
186 // A sample planted before the video script existed gets it, signed like the rest it was planted with.
187 for (const id of draftMissingVideoScripts(db)) {
188 const row = db
189 .query<{ name: string | null; via: string | null }, [number]>(
190 "SELECT approved_by_name AS name, approved_via AS via FROM asset WHERE listing_id = ?1 AND kind = 'mls'",
191 )
192 .get(id);
193 if (row?.via === "sample" && row.name !== null) {
194 db.query(
195 "UPDATE asset SET approved_by_id = 'sample', approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = 'sample' WHERE listing_id = ?1 AND kind = 'video_script'",
196 ).run(id, row.name);
197 redraftUpdate(db, id);
198 }
199 }
186200 // The sample planted before the seller update existed gets its draft, signed like the rest it reports.
187201 for (const id of draftMissingUpdates(db)) {
188202 const row = db
src/status.test.ts +8 −8
@@ -77,7 +77,7 @@ test("the status words: in every tone, clear of Fair Housing, naming the street
7777 test("setting the status adds the two assets, each needing its own approval, and leaves the rest alone", () => {
7878 const { db, id } = launch();
7979 expect(get(db, id)).toMatchObject({ status: "active", sold_price: null });
80 expect(get(db, id).assets).toHaveLength(7);
80 expect(get(db, id).assets).toHaveLength(8);
8181 const mls = get(db, id).assets.find((a) => a.kind === "mls")!;
8282 expect(editAsset(db, me, { id, kind: "mls", text: "My own words about the house." }).ok).toBe(true);
8383 const fresh = get(db, id).assets.find((a) => a.kind === "mls")!;
@@ -87,7 +87,7 @@ test("setting the status adds the two assets, each needing its own approval, and
8787 const sold = setStatus(db, me, { id, status: "sold", sold_price: "752000" });
8888 if (!sold.ok) throw new Error(sold.message);
8989 expect(sold.value).toMatchObject({ status: "sold", sold_price: 752000 });
90 expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "email_status", "email_update"]);
90 expect(sold.value.assets.map((a) => a.kind)).toEqual(["mls", "social_short", "social_medium", "social_carousel", "social_status", "flyer", "email", "video_script", "email_status", "email_update"]);
9191 const mine = sold.value.assets.find((a) => a.kind === "mls")!;
9292 expect(mine.text).toBe("My own words about the house.");
9393 expect(mine.approval).not.toBeNull();
@@ -100,7 +100,7 @@ test("setting the status adds the two assets, each needing its own approval, and
100100
101101 // The count grows to include them.
102102 const summary = listListings(db, me);
103 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(9);
103 expect(summary.ok && summary.value[0]!.approvals).toHaveLength(10);
104104 expect(summary.ok && summary.value[0]!.approvals.filter((a) => a.approved)).toHaveLength(1);
105105
106106 // Each is approved on its own.
@@ -133,7 +133,7 @@ test("setting the status adds the two assets, each needing its own approval, and
133133 const resold = setStatus(db, me, { id, status: "sold" });
134134 expect(resold.ok).toBe(true);
135135 const away = setStatus(db, me, { id, status: "active" });
136 expect(away.ok && away.value.assets).toHaveLength(7);
136 expect(away.ok && away.value.assets).toHaveLength(8);
137137 });
138138
139139 test("who may: the listing's agent or an admin, nobody else, and a bad status changes nothing", () => {
@@ -168,7 +168,7 @@ test("the pill, the flyer band and the sample", () => {
168168 const page = listingPage({ who: me, listing: sold.value, editable: true });
169169 expect(page).toContain(`action="/listings/${id}/status"`);
170170 expect(page).toContain('<option value="sold" selected>');
171 expect(page).toContain("0 of 9 approved");
171 expect(page).toContain("0 of 10 approved");
172172 expect(page).toContain('id="social_status"');
173173 expect(page).toContain('id="email_status"');
174174 expect(listingPage({ who: other, listing: sold.value, editable: false })).not.toContain("/status");
@@ -180,7 +180,7 @@ test("the pill, the flyer band and the sample", () => {
180180 const sample = listed.ok ? listed.value.filter((l) => l.status === "sold") : [];
181181 expect(sample.map((l) => l.address)).toEqual([SAMPLE_CONDO.address]);
182182 expect(sample[0]!.sold_price).toBe(752000);
183 expect(sample[0]!.approvals).toHaveLength(9);
183 expect(sample[0]!.approvals).toHaveLength(10);
184184 expect(sample[0]!.flags).toBe(0);
185185
186186 // A database seeded before statuses existed gets the sold sample once, and keeps a later change.
@@ -190,7 +190,7 @@ test("the pill, the flyer band and the sample", () => {
190190 seeded.run("DELETE FROM seeded WHERE name = 'sample-condo-sold'");
191191 seed(seeded);
192192 expect(get(seeded, meridian)).toMatchObject({ status: "sold", sold_price: 752000 });
193 expect(get(seeded, meridian).assets).toHaveLength(9);
193 expect(get(seeded, meridian).assets).toHaveLength(10);
194194 expect(setStatus(seeded, admin, { id: meridian, status: "active" }).ok).toBe(true);
195195 seed(seeded);
196196 expect(get(seeded, meridian).status).toBe("active");
@@ -241,7 +241,7 @@ test("the page and the API", async () => {
241241 expect(sold.status).toBe(200);
242242 const body = (await sold.json()) as { status: string; sold_price: number; assets: { kind: string }[] };
243243 expect(body).toMatchObject({ status: "sold", sold_price: 752000 });
244 expect(body.assets).toHaveLength(9);
244 expect(body.assets).toHaveLength(10);
245245 expect((await patch({ status: "pending" })).status).toBe(422);
246246 expect((await patch({ status: "active" }, { ...mint("someone-else"), "content-type": "application/json" })).status).toBe(403);
247247
src/tty.test.ts +11 −11
@@ -113,10 +113,10 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is
113113 const { db } = fresh();
114114 const tty = session(db, me);
115115 tty.press("\r");
116 expect(plain(tty.screen())).toContain("0 of 7 approved");
116 expect(plain(tty.screen())).toContain("0 of 8 approved");
117117 tty.press("a");
118118 expect(plain(tty.screen())).toContain("Approved.");
119 expect(plain(tty.screen())).toContain("1 of 7 approved");
119 expect(plain(tty.screen())).toContain("1 of 8 approved");
120120 expect(plain(tty.screen())).toContain("approved by me");
121121 const stored = db.query<{ approved_via: string }, []>("SELECT approved_via FROM asset WHERE approved_via IS NOT NULL").all();
122122 expect(stored.map((r) => r.approved_via)).toEqual(["tty"]);
@@ -125,7 +125,7 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is
125125 const theirs = session(db, other);
126126 theirs.press("\r");
127127 theirs.press("a");
128 expect(plain(theirs.screen())).toContain("0 of 7 approved");
128 expect(plain(theirs.screen())).toContain("0 of 8 approved");
129129 });
130130
131131 test("p previews the email as the client reads it; esc returns to the assets", () => {
@@ -146,12 +146,12 @@ test("p previews the email as the client reads it; esc returns to the assets", (
146146 test("the listings view says what is approved, and the email preview is a draft until approved", () => {
147147 const { db } = fresh();
148148 const tty = session(db, me);
149 expect(plain(tty.screen())).toContain("0 of 7 approved");
149 expect(plain(tty.screen())).toContain("0 of 8 approved");
150150 tty.press("\r");
151151 tty.press("p");
152152 expect(plain(tty.screen())).toContain("DRAFT — not approved");
153153 tty.press("\x1b");
154 for (let i = 0; i < 7; i++) {
154 for (let i = 0; i < 8; i++) {
155155 tty.press("a");
156156 tty.press("j");
157157 }
@@ -211,10 +211,10 @@ test("the terminal shows a social post's length, and a short post over X needs a
211211
212212 tty.press("a");
213213 expect(plain(tty.screen())).toContain("Too long for X by 34. Press a again to approve it anyway.");
214 expect(plain(tty.screen())).toContain("0 of 7 approved");
214 expect(plain(tty.screen())).toContain("0 of 8 approved");
215215 tty.press("a");
216216 expect(plain(tty.screen())).toContain("Approved, too long for X.");
217 expect(plain(tty.screen())).toContain("1 of 7 approved");
217 expect(plain(tty.screen())).toContain("1 of 8 approved");
218218 // Another key between the two presses starts the question again.
219219 tty.press("a"); // withdraw
220220 tty.press("a");
@@ -237,10 +237,10 @@ test("the terminal shows the MLS description's count, and one over 1,000 needs a
237237
238238 tty.press("a");
239239 expect(plain(tty.screen())).toContain("Too long for the MLS remarks by 3. Press a again to approve it anyway.");
240 expect(plain(tty.screen())).toContain("0 of 7 approved");
240 expect(plain(tty.screen())).toContain("0 of 8 approved");
241241 tty.press("a");
242242 expect(plain(tty.screen())).toContain("Approved, too long for the MLS.");
243 expect(plain(tty.screen())).toContain("1 of 7 approved");
243 expect(plain(tty.screen())).toContain("1 of 8 approved");
244244 const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all();
245245 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_mls"]);
246246 });
@@ -312,7 +312,7 @@ test("R shows the seller report: a draft is marked and never worded, an approved
312312 seen = plain(tty.screen());
313313 expect(seen).toContain("Approved by me on");
314314 expect(seen).toContain(words);
315 expect(seen).toContain("1 of 6 assets approved");
315 expect(seen).toContain("1 of 7 assets approved");
316316 expect(seen).toContain("Draft — not approved yet");
317317 expect(seen).toContain(`/homes/${id}`);
318318 });
@@ -435,7 +435,7 @@ test("the seller update is the last asset: it reads as a draft, and a approves i
435435 const { db } = fresh();
436436 const tty = session(db, me);
437437 tty.press("\r");
438 for (let i = 0; i < 6; i++) tty.press("j");
438 for (let i = 0; i < 7; i++) tty.press("j");
439439 const seen = plain(tty.screen());
440440 expect(seen).toContain("▸ Seller update");
441441 expect(seen).toContain("Subject: Your weekly update — 412 Alder Lane");
src/update.test.ts +1 −1
@@ -150,7 +150,7 @@ test("the Alder Lane sample's update is approved, lists its nine assets and the
150150 const row = db.query<{ id: number }, []>("SELECT id FROM listing WHERE address LIKE '412 Alder%'").get()!;
151151 const a = read(db, row.id).assets.find((x) => x.kind === "email_update")!;
152152 expect(a.approval).toMatchObject({ via: "sample" });
153 expect(a.text.match(/Approved by Morgan Reyes \(sample\) on /g)).toHaveLength(9);
153 expect(a.text.match(/Approved by Morgan Reyes \(sample\) on /g)).toHaveLength(10);
154154 expect(a.text).toContain(`/homes/${row.id}`);
155155 expect(check(a.text).clear).toBe(true);
156156 });
src/videophotos.test.ts added +61
@@ -0,0 +1,61 @@
1 /** The video script follows the photos it names: removing, reordering or describing one writes it again. */
2 import { expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { approveAsset, getListing, insertListing, updatePhotos } from "./actions.ts";
7 import { openDb } from "./db.ts";
8 import { SAMPLE } from "./sample.ts";
9 import type { Who } from "./who.ts";
10
11 const pat: Who = { role: "user", id: "pat", name: "Pat" };
12 const setup = () => {
13 const db = openDb(mkdtempSync(join(tmpdir(), "videophotos-")));
14 const { id } = insertListing(db, pat, SAMPLE, ["sample-front.svg", "sample-garden.svg", "sample-condo-front.svg"], null);
15 const script = () => {
16 const r = getListing(db, pat, { id });
17 if (!r.ok) throw new Error(r.message);
18 return r.value.assets.find((a) => a.kind === "video_script")!;
19 };
20 const approve = () => {
21 const a = script();
22 const done = approveAsset(db, pat, { id, kind: "video_script", door: "page", seen: a.updated_at });
23 expect(done.ok).toBe(true);
24 };
25 return { db, id, script, approve };
26 };
27 const shown = (text: string) => [...text.matchAll(/Show photo (\d+)/g)].map((m) => Number(m[1]));
28
29 test("removing a photo writes the script again, naming only photos left, and clears its approval", () => {
30 const { db, id, script, approve } = setup();
31 expect(Math.max(...shown(script().text))).toBe(3);
32 approve();
33 expect(script().approval).not.toBeNull();
34 expect(updatePhotos(db, pat, { id, remove: ["sample-condo-front.svg"] }).ok).toBe(true);
35 expect(Math.max(...shown(script().text))).toBe(2);
36 expect(script().approval).toBeNull();
37 });
38
39 test("reordering clears the approval, and describing a photo shows in the script", () => {
40 const { db, id, script, approve } = setup();
41 approve();
42 expect(updatePhotos(db, pat, { id, order: ["sample-garden.svg", "sample-front.svg", "sample-condo-front.svg"] }).ok).toBe(true);
43 expect(script().approval).toBeNull();
44 approve();
45 expect(updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Back garden" } }).ok).toBe(true);
46 expect(script().text).toContain("Show photo 1 (Back garden)");
47 expect(script().approval).toBeNull();
48 });
49
50 test("an edited script keeps the agent's words but loses its approval when photos are removed", async () => {
51 const { db, id, script } = setup();
52 const { editAsset } = await import("./actions.ts");
53 const edit = editAsset(db, pat, { id, kind: "video_script", text: 'Shot 1/1 · Show photo 3\nSay: "Mine."\nCaption: Mine' });
54 expect(edit.ok).toBe(true);
55 const a = script();
56 approveAsset(db, pat, { id, kind: "video_script", door: "page", seen: a.updated_at });
57 expect(script().approval).not.toBeNull();
58 updatePhotos(db, pat, { id, remove: ["sample-condo-front.svg"] });
59 expect(script().text).toContain('Say: "Mine."');
60 expect(script().approval).toBeNull();
61 });
src/walkthrough.test.ts +14 −14
@@ -123,8 +123,8 @@ test("every step of the section works, with the labels it names", async () => {
123123
124124 // 4. Approve one.
125125 const page = await (await get(mine)).text();
126 expect(page).toContain("0 of 7 approved");
127 expect(section).toContain("1 of 7 approved");
126 expect(page).toContain("0 of 8 approved");
127 expect(section).toContain("1 of 8 approved");
128128 expect(textareaOf(page)).toBe("My own words about 9 Walkthrough Way, written by hand.");
129129 const approveForm = new FormData();
130130 approveForm.set("approved", "1");
@@ -134,7 +134,7 @@ test("every step of the section works, with the labels it names", async () => {
134134 const approved = await post(`${mine}/assets/mls/approve`, approveForm);
135135 expect(approved.status).toBe(303);
136136 const afterApprove = await (await get(mine)).text();
137 expect(afterApprove).toContain("1 of 7 approved");
137 expect(afterApprove).toContain("1 of 8 approved");
138138
139139 // 5. Change the tone: every asset again, approval cleared.
140140 expect(afterApprove).toMatch(control("Rewrite all"));
@@ -142,13 +142,13 @@ test("every step of the section works, with the labels it names", async () => {
142142 tone.set("tone", "bold");
143143 expect((await post(`${mine}/tone`, tone)).status).toBe(303);
144144 const afterTone = await (await get(mine)).text();
145 expect(afterTone).toContain("0 of 7 approved");
145 expect(afterTone).toContain("0 of 8 approved");
146146 // The words were written again: the edit is gone and the text is the bold tone's.
147147 expect(textareaOf(afterTone)).not.toContain("written by hand");
148148 expect(textareaOf(afterTone)).not.toBe(textareaOf(afterApprove));
149149 expect(afterTone).toContain('<option value="bold" selected>');
150150 expect(textareaOf(afterApprove)).toContain("written by hand");
151 expect(section).toContain("0 of 7 approved");
151 expect(section).toContain("0 of 8 approved");
152152
153153 // 6. Put back the MLS words: Earlier versions, Restore, checked again, approval clear.
154154 expect(section).toContain("**Earlier versions (1)**");
@@ -163,7 +163,7 @@ test("every step of the section works, with the labels it names", async () => {
163163 expect((await post(restoreForm[1]!, put)).status).toBe(303);
164164 const afterRestore = await (await get(mine)).text();
165165 expect(textareaOf(afterRestore)).toBe("My own words about 9 Walkthrough Way, written by hand.");
166 expect(afterRestore).toContain("0 of 7 approved");
166 expect(afterRestore).toContain("0 of 8 approved");
167167
168168 // 7. Drop the price: Edit facts, the new price on the flyer, approvals cleared.
169169 expect(section).toContain("**Edit facts**");
@@ -178,7 +178,7 @@ test("every step of the section works, with the labels it names", async () => {
178178 approveAgain.set("seen", /name="seen" value="([^"]+)"/.exec(afterRestore)![1]!.replaceAll("&amp;", "&"));
179179 approveAgain.set("text", textareaOf(afterRestore));
180180 await post(`${mine}/assets/mls/approve`, approveAgain);
181 expect(await (await get(mine)).text()).toContain("1 of 7 approved");
181 expect(await (await get(mine)).text()).toContain("1 of 8 approved");
182182 const drop = new FormData();
183183 for (const k of ["beds", "baths", "sqft", "lot", "year", "tone", "palette",
184184 "agent_name", "brokerage", "license", "agent_phone", "agent_email"] as const)
@@ -189,7 +189,7 @@ test("every step of the section works, with the labels it names", async () => {
189189 expect((await post(`${mine}/facts`, drop)).status).toBe(303);
190190 const afterDrop = await (await get(mine)).text();
191191 expect(afterDrop).toContain("$649,000");
192 expect(afterDrop).toContain("0 of 9 approved");
192 expect(afterDrop).toContain("0 of 10 approved");
193193 expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000");
194194
195195 // 7. Add an open house: Edit facts, the band on the flyer, the line in the email, approvals cleared.
@@ -207,7 +207,7 @@ test("every step of the section works, with the labels it names", async () => {
207207 approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&amp;", "&"));
208208 approveOpen.set("text", textareaOf(beforeOpen));
209209 await post(`${mine}/assets/mls/approve`, approveOpen);
210 expect(await (await get(mine)).text()).toContain("1 of 9 approved");
210 expect(await (await get(mine)).text()).toContain("1 of 10 approved");
211211 const hold = new FormData();
212212 for (const [k, v] of drop.entries()) hold.set(k, v);
213213 hold.set("tone", "bold");
@@ -216,7 +216,7 @@ test("every step of the section works, with the labels it names", async () => {
216216 hold.set("open1_end", "15:00");
217217 expect((await post(`${mine}/facts`, hold)).status).toBe(303);
218218 const afterOpen = await (await get(mine)).text();
219 expect(afterOpen).toContain("0 of 12 approved");
219 expect(afterOpen).toContain("0 of 13 approved");
220220 expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
221221 expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm.");
222222 const bad = new FormData();
@@ -231,7 +231,7 @@ test("every step of the section works, with the labels it names", async () => {
231231
232232 // 8. Mark it sold: the status post and email, eight to approve, the band, the list.
233233 expect(section).toContain("**Mark it sold.**");
234 expect(section).toContain("0 of 9 approved");
234 expect(section).toContain("0 of 10 approved");
235235 expect(section).toContain("88 Meridian Street");
236236 expect(afterTone).toContain('name="status"');
237237 expect(afterTone).toContain('<option value="sold">');
@@ -241,20 +241,20 @@ test("every step of the section works, with the labels it names", async () => {
241241 sell.set("sold_price", "640000");
242242 expect((await post(`${mine}/status`, sell)).status).toBe(303);
243243 const afterSold = await (await get(mine)).text();
244 expect(afterSold).toContain("0 of 11 approved");
244 expect(afterSold).toContain("0 of 12 approved");
245245 expect(afterSold).toContain("Sold at $640,000");
246246 expect(afterSold).toContain("Just sold — 9 Walkthrough Way");
247247 expect(await (await get(`${mine}/flyer`)).text()).toContain('<p class="flyer-status status-sold" role="note">Sold at $640,000</p>');
248248 const homeSold = await (await get("/")).text();
249249 expect(homeSold).toContain("Sold at $640,000");
250 expect(homeSold).toContain("0 of 9 approved");
250 expect(homeSold).toContain("0 of 10 approved");
251251 expect(homeSold).toContain("Sold at $752,000");
252252 const meridian = await (await get(`/listings/${idOf(homeSold, "88 Meridian Street")}`)).text();
253253 expect(meridian).toContain("Just sold — 88 Meridian Street");
254254 const active = new FormData();
255255 active.set("status", "active");
256256 expect((await post(`${mine}/status`, active)).status).toBe(303);
257 expect(await (await get(mine)).text()).toContain("0 of 9 approved");
257 expect(await (await get(mine)).text()).toContain("0 of 10 approved");
258258
259259 // 9. Print the flyer.
260260 expect(afterTone).toMatch(control("Print flyer"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs