Merge pull request #303 from magnus919/feat/travel-guide-visual-system

feat(skill): travel-guide visual system — journey line, day strip, meters, photo grade
This commit is contained in:
Magnus Hedemark
2026-08-08 15:27:42 -04:00
committed by GitHub
11 changed files with 256 additions and 10 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ Turn a destination, a real traveler, and a few constraints into a considered tra
Most itinerary tools optimize for coverage. This skill helps an agent design for fit: the pace, people, budget, interests, energy, and small details that make a trip feel like it belongs to the travelers.
It can produce a print-ready HTML dossier for PDF conversion, a responsive companion page, or both. It keeps current logistics and recommendations tied to sources, and it can create a sanitized edition for sharing without exposing exact dates, lodging, booking identifiers, or private notes.
It can produce a print-ready HTML dossier for PDF conversion, a responsive companion page, or both. The visual system is editorial by default: a darkened photographic cover with a route journey line, ghost section numbers, a color-coded day strip, pace and budget meters, and a unified warm photo grade across anchor photos. It keeps current logistics and recommendations tied to sources, and it can create a sanitized edition for sharing without exposing exact dates, lodging, booking identifiers, or private notes.
## What You Get
+6 -4
View File
@@ -188,10 +188,12 @@ Adapt the length to the trip, but preserve the information hierarchy:
7. skip this: attractive but poor-fit options, where useful;
8. sources and freshness: links, retrieval dates, and unresolved uncertainty.
The visual default is a dark photographic cover, warm gold eyebrow, white
headline, restrained red accent, generous white content pages, numbered
sections, compact cards, and readable tables. Preserve contrast and selectable
text. Do not let decoration hide uncertainty or practical caveats.
The visual default is a dark photographic cover with a route journey line, warm
gold eyebrow, white headline, restrained red accent, generous white content
pages, ghost section numbers, a color-coded day strip right after the brief,
pace and budget meters, compact cards, a unified warm photo grade on anchor
images, and readable tables. Preserve contrast and selectable text. Do not let
decoration hide uncertainty or practical caveats.
## Exit criteria
+10 -1
View File
@@ -79,16 +79,25 @@ surprises that depend on access, money, health, or another person's consent.
The default visual language is editorial rather than app-like:
- a full-bleed, darkened cover photograph;
- a route journey line on the cover (one dot per stop, dashed connector,
night counts) when the route has two or more stops;
- a warm gold eyebrow and restrained red accent;
- large, left-aligned white title text;
- white content pages with generous margins;
- numbered sections and compact, scannable cards;
- ghost section numbers and compact, scannable cards;
- a color-coded day strip right after the brief, one card per day, with the
day's kind (arrive, city, excursion, coast) driving the card color;
- pace and budget meters in the brief when the trip model supplies them;
- a unified warm photo grade on anchor images so mixed-source photos read as
one editorial set;
- dark table headers with clear column labels;
- short captions and visible image credits.
Use images to establish place and texture, not to imply that an image proves a
recommendation. Keep body text selectable and readable in grayscale or with
high-contrast settings. Every meaningful image needs useful alternative text.
The day strip is a glanceable overview, not a schedule: it must never invent a
timed plan that the day cards do not support.
## Companion web page
+5
View File
@@ -63,9 +63,14 @@ Before delivery, verify all of the following:
- every major section begins on a fresh page;
- text remains selectable;
- the cover image and every intended local image are present;
- the cover journey line renders when the route has two or more stops;
- the day strip ("trip at a glance") shows one card per day with legible kind
colors, and the meters render when pace or budget are supplied;
- ghost section numbers do not collide with content;
- no page is blank, clipped, or unexpectedly split;
- title, tables, captions, and source URLs are readable;
- contrast works on the dark cover and in grayscale content pages;
- anchor photos carry the unified warm grade and remain legible;
- page count is consistent with the requested scope;
- links and document metadata are set when the renderer supports them;
- the source JSON and renderer output are retained for regeneration.
@@ -81,6 +81,24 @@ Do not book or purchase anything. A booking link is a pointer, not evidence that
availability exists. Mark availability as unverified unless the user or a
booking tool has explicitly confirmed it.
## Photo sourcing
Photos are part of the visual contract: the cover and anchor cards take local
images from the trip model, and the renderer embeds them into the artifact. The
traveler's own photos are the best source. For anything else:
- prefer free-license sources (for example Wikimedia Commons with a CC0, CC BY,
or CC BY-SA license) over scraped web images;
- record the author and license in the image credit field so the rendered
dossier can show it;
- download the file into the working folder and reference it by relative path
so the renderer embeds it; never hotlink an arbitrary web image into a
private artifact;
- give every image a descriptive alt attribute that says what the photo shows,
not what it proves;
- do not use a photo to imply that a recommendation is verified. A picture of a
famous site is not a source for its opening hours.
## Research stop conditions
Stop and report a limitation when:
+86 -3
View File
@@ -70,6 +70,84 @@ def render_mark():
return ""
def render_journey(trip):
"""Cover route line: one dot per stop, dashed connector, night counts."""
route = [r for r in trip.get("route", []) if isinstance(r, dict) and r.get("place")]
if len(route) < 2:
return ""
pad, cy, top_y, label_y = 46.0, 40.0, 26.0, 64.0
xs = [pad + (740.0 - 2 * pad) * i / (len(route) - 1) for i in range(len(route))]
line = '<path d="%s" stroke="rgba(255,253,248,.5)" stroke-width="2.5" stroke-dasharray="1 9" stroke-linecap="round" fill="none"/>' % " L".join(
"%.1f %.1f" % (x, cy) for x in xs
)
dots, labels, nights = [], [], []
for i, (x, stop) in enumerate(zip(xs, route)):
dots.append('<circle cx="%.1f" cy="%.1f" r="7" fill="%s" stroke="rgba(255,253,248,.85)" stroke-width="2"/>'
% (x, cy, "#d8a929" if i else "#b51f39"))
labels.append('<text x="%.1f" y="%.1f" text-anchor="middle" fill="rgba(255,253,248,.92)" font-size="15" font-weight="700" font-family="Arial, Helvetica, sans-serif">%s</text>'
% (x, label_y, esc(stop.get("place"))))
nights.append('<text x="%.1f" y="%.1f" text-anchor="middle" fill="rgba(216,169,41,.8)" font-size="11" font-family="Arial, Helvetica, sans-serif">%s</text>'
% (x, top_y, ("%s nights" % stop["nights"]) if stop.get("nights") else "day trip"))
places = ", ".join(str(s.get("place", "")) for s in route)
return ('<svg class="journey" viewBox="0 0 740 82" role="img" aria-label="Route: %s">'
'<title>Route: %s</title>%s%s%s%s</svg>'
% (esc(places), esc(places), line, "".join(dots), "".join(labels), "".join(nights)))
KIND_LABEL = {"arrive": "Arrival", "city": "City", "excursion": "Excursion", "coast": "Coast"}
KIND_CLASS = {"arrive": "kind-arrive", "city": "kind-city", "excursion": "kind-excursion", "coast": "kind-coast"}
def render_glance(brief):
"""Day strip: one color-coded card per day, rendered right after the brief."""
cards = []
for day in brief.get("days", []):
if not isinstance(day, dict):
continue
kind = str(day.get("kind", "")).strip().lower()
cls = KIND_CLASS.get(kind, "kind-default")
kind_label = KIND_LABEL.get(kind, "Day")
label = esc(clean_text(day.get("label"), "Untitled day"))
anchor = esc(clean_text(day.get("anchor"), "No anchor named"))
cards.append('<div class="glance-day %s"><span class="glance-num">Day %s · %s</span><strong>%s</strong><small>%s</small></div>'
% (cls, esc(day.get("day", "")), kind_label, label, anchor))
if not cards:
return ""
has_kinds = any(isinstance(day, dict) and str(day.get("kind", "")).strip() for day in brief.get("days", []))
legend = ('<p class="muted glance-note">Color marks the day\'s kind: arrival, city, excursion, coast. '
'Days without a kind fall back to gold.</p>') if has_kinds else ""
return ('<section class="sheet" id="glance">\n'
' <p class="section-kicker">Trip at a glance</p>\n'
' <h2>The whole trip, one glance.</h2>\n'
' <div class="glance-grid">%s</div>\n%s\n</section>' % ("".join(cards), legend))
PACE_LEVELS = {"slow": 2, "slow to moderate": 3, "moderate": 4, "moderate to high": 4, "high": 5, "fast": 5}
def render_meters(brief):
"""Segmented pace/budget meters in the brief; absent values render as text only."""
trip = brief.get("trip", {})
parts = []
if isinstance(trip, dict):
pace = clean_text(trip.get("pace"), "").lower()
pace_level = PACE_LEVELS.get(pace)
if pace_level is not None:
cells = "".join('<span class="cell %s"></span>' % ("on" if i < pace_level else "off") for i in range(5))
parts.append('<div class="meter"><span class="meter-label">Pace</span><div class="meter-cells" aria-hidden="true">%s</div><span class="meter-value">%s</span></div>'
% (cells, esc(trip.get("pace", ""))))
budget = trip.get("budget", {})
amount = clean_text(budget.get("amount_range") or budget.get("label"), "") if isinstance(budget, dict) else ""
euro_count = amount.count("")
if 1 <= euro_count <= 5:
cells = "".join('<span class="cell %s"></span>' % ("on" if i < euro_count else "off") for i in range(5))
parts.append('<div class="meter"><span class="meter-label">Budget</span><div class="meter-cells" aria-hidden="true">%s</div><span class="meter-value">%s</span></div>'
% (cells, esc(amount)))
if not parts:
return ""
return '<div class="trip-meters">%s</div>' % "".join(parts)
def render_cover(brief, base_dir, warnings):
trip = brief.get("trip", {})
cover = brief.get("cover", {})
@@ -89,6 +167,7 @@ def render_cover(brief, base_dir, warnings):
credit = clean_text(cover.get("image", {}).get("credit")) if isinstance(cover.get("image", {}), dict) else ""
credit_html = '<p class="image-credit">%s</p>' % esc(credit) if credit else ""
region_html = " %s" % esc(region) if region else ""
journey = render_journey(trip)
return """
<section class="cover" id="top">
{image}
@@ -104,6 +183,7 @@ def render_cover(brief, base_dir, warnings):
<div class="cover-stat"><span class="cover-stat-label">Route</span><span class="cover-stat-value">{route_value}</span></div>
<div class="cover-stat"><span class="cover-stat-label">Budget</span><span class="cover-stat-value">{budget_value}</span></div>
</div>
{journey}
{credit_html}
</div>
</section>
@@ -118,6 +198,7 @@ def render_cover(brief, base_dir, warnings):
duration_value=esc(duration_value),
route_value=esc(route_value),
budget_value=esc(budget_value),
journey=journey,
credit_html=credit_html,
)
@@ -137,15 +218,17 @@ def render_brief(brief):
pace = clean_text(trip.get("pace"), "Not specified")
traveler_count = len(trip.get("travelers", [])) if isinstance(trip.get("travelers"), list) else ""
traveler_label = "%s traveler(s)" % traveler_count if traveler_count else "Travel party"
meters = render_meters(brief)
return """
<section class="sheet page-break" id="brief">
<p class="section-kicker">The brief</p>
<h2>Why this trip, now?</h2>
<p class="lede">{thesis}</p>
<div class="route-grid">{route_html}</div>
{meters}
<div class="callout"><h3>Trip posture</h3><p>{pace} · {traveler_label} · The plan protects one meaningful experience at a time and leaves room for the place to interrupt it.</p></div>
</section>
""".format(thesis=esc(thesis), route_html=route_html, pace=esc(pace), traveler_label=esc(traveler_label))
""".format(thesis=esc(thesis), route_html=route_html, meters=meters, pace=esc(pace), traveler_label=esc(traveler_label))
def render_anchors(brief, base_dir, warnings):
@@ -326,10 +409,10 @@ def render_sources(brief):
def render_body(brief, base_dir, warnings, mode):
body = [render_cover(brief, base_dir, warnings), render_brief(brief), render_anchors(brief, base_dir, warnings), render_days(brief), render_special(brief), render_skip(brief), render_practical(brief), render_sources(brief)]
body = [render_cover(brief, base_dir, warnings), render_brief(brief), render_glance(brief), render_anchors(brief, base_dir, warnings), render_days(brief), render_special(brief), render_skip(brief), render_practical(brief), render_sources(brief)]
nav = ""
if mode == "companion":
nav = '<nav class="companion-nav" aria-label="Guide sections"><a href="#brief">Brief</a><a href="#anchors">Anchors</a><a href="#days">Days</a><a href="#special">Special</a><a href="#practical">Field notes</a><a href="#sources">Sources</a></nav>'
nav = '<nav class="companion-nav" aria-label="Guide sections"><a href="#brief">Brief</a><a href="#glance">At a glance</a><a href="#anchors">Anchors</a><a href="#days">Days</a><a href="#special">Special</a><a href="#practical">Field notes</a><a href="#sources">Sources</a></nav>'
return nav + "\n".join(body)
@@ -104,6 +104,9 @@ def validate(data, strict=False):
value = anchor.get(key)
if not isinstance(value, str) or not value.strip():
errors.append("%s.%s is required" % (prefix, key))
image = anchor.get("image")
if image is not None and not isinstance(image, dict):
errors.append("%s.image must be an object" % prefix)
if not isinstance(anchor.get("source_ids", []), list):
errors.append("%s.source_ids must be an array" % prefix)
elif strict and not anchor.get("source_ids"):
@@ -125,6 +128,9 @@ def validate(data, strict=False):
errors.append("%s.%s is required" % (prefix, key))
if not isinstance(day.get("source_ids", []), list):
errors.append("%s.source_ids must be an array" % prefix)
kind = day.get("kind")
if kind is not None and (not isinstance(kind, str) or kind.strip().lower() not in ("arrive", "city", "excursion", "coast")):
warnings.append("%s.kind should be one of arrive, city, excursion, coast" % prefix)
sources = data.get("sources")
if not isinstance(sources, list):
+29 -1
View File
@@ -8,6 +8,7 @@
--gold: #d8a929;
--signal: #b51f39;
--orange: #ed8730;
--teal: #1f7a7a;
--white: #fffdf8;
--serif: Georgia, "Times New Roman", serif;
--sans: Arial, Helvetica, sans-serif;
@@ -49,6 +50,15 @@ img { max-width: 100%; height: auto; }
.cover-stat-label { display: block; color: var(--gold); font-size: .66rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; }
.cover-stat-value { display: block; margin-top: .2rem; font-size: 1.05rem; font-weight: 700; }
.image-credit { margin: 2.5rem 0 0; color: rgba(255,255,255,.7); font-size: .68rem; }
.journey { width: 100%; max-width: 34rem; margin: 1.6rem 0 0; }
.sheet { position: relative; isolation: isolate; counter-increment: sheet; }
.sheet::before {
content: counter(sheet, decimal-leading-zero);
position: absolute; top: 2.6rem; right: 1.2rem; z-index: -1;
font-size: 7.5rem; font-weight: 800; letter-spacing: -.05em; line-height: 1;
color: rgba(23, 25, 27, .055); pointer-events: none;
}
.sheet { max-width: 920px; margin: 0 auto; padding: 4.5rem 8vw; background: var(--paper); }
.sheet + .sheet { border-top: 1px solid var(--line); }
@@ -57,6 +67,7 @@ h2 { margin: 0 0 1.25rem; color: var(--ink); font-size: clamp(1.8rem, 4vw, 3.1re
h3 { margin: 0; color: var(--ink); font-size: 1.15rem; line-height: 1.12; }
p { line-height: 1.52; }
.lede { max-width: 42rem; font-family: var(--serif); font-size: 1.3rem; line-height: 1.45; }
.lede::first-letter { float: left; margin: .12em .12em 0 0; color: var(--signal); font-family: var(--serif); font-size: 3.6em; line-height: .8; font-weight: 700; }
.muted { color: var(--muted); }
.rule { height: 1px; margin: 2rem 0; background: var(--line); border: 0; }
@@ -65,10 +76,27 @@ p { line-height: 1.52; }
.route-card strong, .practical-card strong { display: block; margin-bottom: .35rem; }
.route-card small, .practical-card small { color: var(--muted); }
.trip-meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1.6rem; margin: 1.6rem 0 .4rem; }
.meter-label { display: block; color: var(--signal); font-size: .64rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.meter-cells { display: flex; gap: 3px; margin: .45rem 0 .3rem; }
.meter-cells .cell { width: 1.15rem; height: .5rem; background: var(--line); }
.meter-cells .cell.on { background: var(--gold); }
.meter-value { color: var(--muted); font-size: .82rem; }
.glance-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: .6rem; margin: 1.4rem 0 .6rem; }
.glance-day { padding: .85rem .95rem .9rem; background: var(--white); border-top: 4px solid var(--gold); break-inside: avoid; }
.glance-day.kind-city { border-top-color: var(--signal); }
.glance-day.kind-excursion { border-top-color: var(--orange); }
.glance-day.kind-coast { border-top-color: var(--teal); }
.glance-num { display: block; color: var(--muted); font-size: .64rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
.glance-day strong { display: block; margin: .3rem 0 .25rem; font-size: .95rem; line-height: 1.25; }
.glance-day small { display: block; color: var(--muted); font-size: .78rem; line-height: 1.35; }
.glance-note { margin: .8rem 0 0; font-size: .78rem; }
.anchor-list { display: grid; gap: 1.3rem; }
.anchor-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.1rem; padding: 1.25rem 0; border-top: 1px solid var(--line); break-inside: avoid; }
.anchor-card.has-image { grid-template-columns: 11rem minmax(0, 1fr); }
.anchor-image { width: 100%; height: 8.5rem; border-radius: .25rem; object-fit: cover; background: #d3d0c7; }
.anchor-image { width: 100%; height: 8.5rem; border-radius: .25rem; object-fit: cover; background: #d3d0c7; filter: sepia(.34) saturate(.72) contrast(1.08) brightness(.94); }
.number { display: inline-grid; width: 1.8rem; height: 1.8rem; margin-right: .45rem; place-items: center; border-radius: 50%; background: var(--signal); color: var(--white); font-weight: 800; }
.anchor-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: .65rem; margin-top: 1rem; font-size: .88rem; }
.anchor-meta dt { color: var(--signal); font-size: .64rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
@@ -32,6 +32,7 @@ moment.]
### [Anchor title]
- Place: [specific venue, route, event, or neighborhood]
- Image: [optional local file path, alt text, and credit]
- Why it fits: [concrete mechanism of fit]
- Best window: [time/day/season]
- Cost: [range, currency, and assumptions]
@@ -43,6 +44,8 @@ moment.]
### Day [number] — [label]
- Kind: [optional: arrive, city, excursion, or coast — drives the color of the
day strip on the "trip at a glance" page]
- Anchor: [one thing worth protecting]
- Texture: [meal, market, street, shop, or ordinary-life detail]
- Pause: [recovery space or empty time]
+5
View File
@@ -45,6 +45,7 @@
{
"title": "A quiet first look at the city",
"place": "Replace with a specific viewpoint, museum, or neighborhood route",
"image": {"src": "", "alt": "", "credit": ""},
"why": "It gives the travelers orientation without spending the first day on a checklist.",
"best_window": "Late morning",
"cost": "Add a sourced range",
@@ -55,6 +56,7 @@
{
"title": "A meal worth making the evening about",
"place": "Replace with a specific restaurant or market stall",
"image": {"src": "", "alt": "", "credit": ""},
"why": "The meal is the evening's anchor, so the rest of the day can stay deliberately light.",
"best_window": "Early dinner or the first available counter seating",
"cost": "Add a sourced range and currency",
@@ -65,6 +67,7 @@
{
"title": "An ordinary-life texture",
"place": "Replace with a specific market, shop, tram route, or neighborhood walk",
"image": {"src": "", "alt": "", "credit": ""},
"why": "It makes the trip about how the city feels between headline attractions.",
"best_window": "A weekday morning",
"cost": "Free or add a sourced estimate",
@@ -77,6 +80,7 @@
{
"day": 1,
"label": "Arrive without proving anything",
"kind": "arrive",
"anchor": "A short orientation walk near the base",
"texture": "A first meal chosen for ease, not prestige",
"pause": "Leave the afternoon unassigned",
@@ -87,6 +91,7 @@
{
"day": 2,
"label": "One substantial thing, then drift",
"kind": "city",
"anchor": "Use the first cultural anchor",
"texture": "A named street, market, or small shop nearby",
"pause": "Return before the second major commitment",
+87
View File
@@ -91,6 +91,93 @@ class TravelGuideScriptsTest(unittest.TestCase):
self.assertEqual(render.returncode, 0, render.stderr)
self.assertIn('data-privacy-mode="shareable"', html_output.read_text(encoding="utf-8"))
def _filled_brief(self):
data = json.loads(TEMPLATE.read_text(encoding="utf-8"))
data["trip"]["route"] = [{"place": "Lisbon", "nights": 3}, {"place": "Sintra", "nights": 1}]
data["thesis"] = ("A week in Lisbon with enough structure to protect the good parts of the day "
"and enough slack for the city to interrupt the plan.")
data["anchors"] = [{
"title": "A quiet first look",
"place": "Miradouro da Graça",
"why": "It gives the travelers orientation without a checklist.",
"best_window": "Late morning",
"cost": "Free",
"booking": "No booking",
"failure_mode": "Rain sends the walk indoors.",
"source_ids": ["S1"],
}]
data["days"] = [{
"day": 1,
"label": "Arrive without proving anything",
"kind": "arrive",
"anchor": "A short orientation walk",
"texture": "A first meal chosen for ease",
"pause": "Leave the afternoon unassigned",
"alternative": "A nearby café and an early night.",
"practical": "Transfer and check-in caveat.",
"source_ids": ["S4"],
}]
return data
def test_renderer_adds_journey_line_for_multi_stop_route(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
brief = directory / "brief.json"
brief.write_text(json.dumps(self._filled_brief()), encoding="utf-8")
output = directory / "dossier.html"
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
self.assertEqual(result.returncode, 0, result.stderr)
rendered = output.read_text(encoding="utf-8")
self.assertIn('class="journey"', rendered)
self.assertIn("Sintra", rendered)
self.assertIn("3 nights", rendered)
def test_renderer_adds_glance_strip_and_meters(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
brief = directory / "brief.json"
brief.write_text(json.dumps(self._filled_brief()), encoding="utf-8")
output = directory / "dossier.html"
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
self.assertEqual(result.returncode, 0, result.stderr)
rendered = output.read_text(encoding="utf-8")
self.assertIn('id="glance"', rendered)
self.assertIn('class="glance-day kind-arrive"', rendered)
self.assertIn('class="trip-meters"', rendered)
self.assertIn("Slow to moderate", rendered)
def test_renderer_skips_journey_line_for_single_stop_route(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
brief = directory / "brief.json"
data = self._filled_brief()
data["trip"]["route"] = [{"place": "Lisbon", "nights": 7}]
brief.write_text(json.dumps(data), encoding="utf-8")
output = directory / "dossier.html"
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
self.assertEqual(result.returncode, 0, result.stderr)
rendered = output.read_text(encoding="utf-8")
self.assertNotIn('class="journey"', rendered)
def test_visual_system_css_is_present(self):
css = (ROOT / "styles" / "travel-dossier.css").read_text(encoding="utf-8")
self.assertIn("counter-increment: sheet", css)
self.assertIn(".glance-grid", css)
self.assertIn(".trip-meters", css)
self.assertIn("filter: sepia", css)
def test_validator_warns_on_unknown_kind(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
brief = directory / "brief.json"
data = self._filled_brief()
data["days"][0]["kind"] = "mountain"
brief.write_text(json.dumps(data), encoding="utf-8")
result = run_script("validate-trip-brief.py", brief, "--json")
self.assertEqual(result.returncode, 0)
payload = json.loads(result.stdout)
self.assertTrue(any("kind" in warning for warning in payload["warnings"]))
if __name__ == "__main__":
unittest.main()