Compare commits

..

3 Commits

Author SHA1 Message Date
2569718930@qq.com 1a5bab9fad Fix DEB training truth finality handling 2026-06-24 18:18:45 +08:00
2569718930@qq.com 0fef948fac Localize public brief pages 2026-06-24 18:14:35 +08:00
2569718930@qq.com 07c5451f1d Allow manual payment tx submission during transient validation 2026-06-24 17:03:45 +08:00
14 changed files with 773 additions and 141 deletions
+54 -21
View File
@@ -1,17 +1,38 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { cookies, headers } from "next/headers";
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import {
PUBLIC_BRIEFS,
absolutePublicUrl,
briefPath,
getBrief,
localizeBrief,
} from "@/content/public-content";
type BriefPageParams = {
city: string;
date: string;
};
type BriefSearchParams = Promise<Record<string, string | string[] | undefined>>;
async function resolvePublicContentLocale(searchParams: BriefSearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export function generateStaticParams() {
return PUBLIC_BRIEFS.map((brief) => ({
@@ -22,10 +43,15 @@ export function generateStaticParams() {
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<BriefPageParams>;
searchParams: BriefSearchParams;
}): Promise<Metadata> {
const { city, date } = await params;
const [{ city, date }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const brief = getBrief(city, date);
if (!brief) {
@@ -34,51 +60,58 @@ export async function generateMetadata({
};
}
const pathname = briefPath(brief);
const localizedBrief = localizeBrief(brief, locale);
const pathname = briefPath(localizedBrief);
return {
title: brief.title,
description: brief.description,
title: localizedBrief.title,
description: localizedBrief.description,
alternates: {
canonical: pathname,
},
openGraph: {
type: "article",
title: brief.title,
description: brief.description,
title: localizedBrief.title,
description: localizedBrief.description,
url: pathname,
publishedTime: brief.publishedAt,
modifiedTime: brief.updatedAt,
publishedTime: localizedBrief.publishedAt,
modifiedTime: localizedBrief.updatedAt,
},
twitter: {
card: "summary",
title: brief.title,
description: brief.description,
title: localizedBrief.title,
description: localizedBrief.description,
},
};
}
export default async function BriefDetailPage({
params,
searchParams,
}: {
params: Promise<BriefPageParams>;
searchParams: BriefSearchParams;
}) {
const { city, date } = await params;
const [{ city, date }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const brief = getBrief(city, date);
if (!brief) {
notFound();
}
const pathname = briefPath(brief);
const localizedBrief = localizeBrief(brief, locale);
const pathname = briefPath(localizedBrief);
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "Article",
headline: brief.title,
description: brief.description,
datePublished: brief.publishedAt,
dateModified: brief.updatedAt,
headline: localizedBrief.title,
description: localizedBrief.description,
datePublished: localizedBrief.publishedAt,
dateModified: localizedBrief.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
@@ -91,9 +124,9 @@ export default async function BriefDetailPage({
url: "https://polyweather.top",
},
about: [
brief.cityName,
brief.market,
brief.settlementSource,
localizedBrief.cityName,
localizedBrief.market,
localizedBrief.settlementSource,
"DEB forecast methodology",
],
},
@@ -110,7 +143,7 @@ export default async function BriefDetailPage({
{
"@type": "ListItem",
position: 2,
name: brief.cityName,
name: localizedBrief.cityName,
item: absolutePublicUrl(pathname),
},
],
@@ -123,7 +156,7 @@ export default async function BriefDetailPage({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<BriefDetailPageView brief={brief} />
<BriefDetailPageView brief={brief} locale={locale} />
</>
);
}
+49 -16
View File
@@ -1,21 +1,54 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
export const metadata: Metadata = {
title: "Weather Market Brief",
description:
"Public PolyWeather market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
alternates: {
canonical: "/briefs",
},
openGraph: {
title: "Weather Market Brief | PolyWeather",
description:
"Public market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
url: "/briefs",
},
};
type BriefsSearchParams = Promise<Record<string, string | string[] | undefined>>;
export default function BriefsPage() {
return <BriefsIndexPageView />;
async function resolvePublicContentLocale(searchParams: BriefsSearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export async function generateMetadata({
searchParams,
}: {
searchParams: BriefsSearchParams;
}): Promise<Metadata> {
const locale = await resolvePublicContentLocale(searchParams);
const copy = PUBLIC_CONTENT_COPY[locale];
return {
title: copy.briefIndexEyebrow,
description: copy.briefIndexDescription,
alternates: {
canonical: "/briefs",
},
openGraph: {
title: `Weather Market Brief | PolyWeather`,
description: copy.briefIndexDescription,
url: "/briefs",
},
};
}
export default async function BriefsPage({
searchParams,
}: {
searchParams: BriefsSearchParams;
}) {
const locale = await resolvePublicContentLocale(searchParams);
return <BriefsIndexPageView locale={locale} />;
}
@@ -94,6 +94,12 @@ export function runTests() {
paymentFlowSource.includes("/validate"),
"manual payment flow must validate tx hashes with the backend before submission",
);
assert(
paymentFlowSource.includes("confirmRes.status === 503") &&
paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') &&
paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'),
"manual payment confirm must treat transient payment RPC failures as pending after tx hash submission",
);
assert(
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
@@ -826,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||
(confirmRes.status === 503 &&
(lowerRaw.includes("cannot connect payment rpc") ||
lowerRaw.includes("payment rpc chain mismatch"))) ||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
@@ -325,7 +325,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
{isEn ? "Guide" : "读图"}
</Link>
<Link href="/briefs" className="hover:text-slate-950">
Weather Market Brief
{isEn ? "Briefs" : "简报"}
</Link>
<a href="#pricing" className="hover:text-slate-950">
{isEn ? "Pricing" : "定价"}
@@ -105,7 +105,7 @@ export function runTests() {
);
assert(source.includes("#contact"), "landing navigation must expose the contact section");
assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets");
assert(source.includes("Weather Market Brief"), "landing page must label the public brief entry clearly");
assert(source.includes('isEn ? "Briefs" : "简报"'), "landing page must label the public brief entry in both languages");
assert(source.includes('id="contact"'), "landing page must include a contact section");
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
@@ -1,12 +1,19 @@
import Link from "next/link";
import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
import type { LandingLocale } from "@/components/landing/landingLocale";
import {
METHODOLOGY_PAGES,
PUBLIC_CONTENT_COPY,
PUBLIC_BRIEFS,
SOURCE_PAGES,
absolutePublicUrl,
briefPath,
methodologyPath,
sourcePath,
localizeBrief,
localizeBriefs,
localizeMethodologyPage,
localizeSourcePage,
type MethodologyPage,
type PublicBrief,
type SourcePage,
@@ -31,27 +38,32 @@ const primaryButton =
const secondaryButton =
"inline-flex min-h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 hover:bg-slate-50";
function PublicHeader() {
function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) {
const copy = PUBLIC_CONTENT_COPY[locale];
return (
<header className="border-b border-slate-200 bg-white/90">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<Link className="text-base font-black text-slate-950" href="/">
PolyWeather
</Link>
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
Briefs
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
Methodology
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
Sources
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
Docs
</Link>
</nav>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
{locale === "en-US" ? "Briefs" : "简报"}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
{copy.methodology}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
{copy.sources}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
{copy.docs}
</Link>
</nav>
<LandingLocaleToggle locale={locale} />
</div>
</div>
</header>
);
@@ -81,19 +93,20 @@ function PageIntro({
);
}
function SourceLinks({ slugs }: { slugs: string[] }) {
function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
return (
<div className="flex flex-wrap gap-2">
{slugs.map((slug) => {
const source = SOURCE_PAGES.find((entry) => entry.slug === slug);
if (!source) return null;
const localizedSource = localizeSourcePage(source, locale);
return (
<Link
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
href={sourcePath(source)}
href={sourcePath(localizedSource)}
key={slug}
>
{source.title}
{localizedSource.title}
</Link>
);
})}
@@ -101,19 +114,20 @@ function SourceLinks({ slugs }: { slugs: string[] }) {
);
}
function MethodologyLinks({ slugs }: { slugs: string[] }) {
function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
return (
<div className="flex flex-wrap gap-2">
{slugs.map((slug) => {
const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug);
if (!page) return null;
const localizedPage = localizeMethodologyPage(page, locale);
return (
<Link
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
href={methodologyPath(page)}
href={methodologyPath(localizedPage)}
key={slug}
>
{page.title}
{localizedPage.title}
</Link>
);
})}
@@ -121,7 +135,15 @@ function MethodologyLinks({ slugs }: { slugs: string[] }) {
);
}
function BriefCard({ brief }: { brief: PublicBrief }) {
function BriefCard({
brief,
locale = "en-US",
readBriefLabel,
}: {
brief: PublicBrief;
locale?: LandingLocale;
readBriefLabel: string;
}) {
return (
<article className={`${panel} flex flex-col gap-5 p-5`}>
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -149,52 +171,60 @@ function BriefCard({ brief }: { brief: PublicBrief }) {
</div>
<div className="flex flex-wrap items-center gap-3">
<Link className={secondaryButton} href={briefPath(brief)}>
Read brief
{readBriefLabel}
</Link>
<SourceLinks slugs={brief.sourceSlugs.slice(0, 2)} />
<SourceLinks locale={locale} slugs={brief.sourceSlugs.slice(0, 2)} />
</div>
</article>
);
}
export function BriefsIndexPageView() {
export function BriefsIndexPageView({ locale = "en-US" }: { locale?: LandingLocale }) {
const copy = PUBLIC_CONTENT_COPY[locale];
const briefs = localizeBriefs(locale);
return (
<div className={pageShell}>
<PublicHeader />
<PublicHeader locale={locale} />
<PageIntro
description="Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer."
eyebrow="Weather Market Brief"
title="Public market briefs for temperature judgment"
description={copy.briefIndexDescription}
eyebrow={copy.briefIndexEyebrow}
title={copy.briefIndexTitle}
/>
<div className={contentWrap}>
<section className="grid gap-4">
{PUBLIC_BRIEFS.map((brief) => (
<BriefCard brief={brief} key={`${brief.city}-${brief.date}`} />
{briefs.map((brief) => (
<BriefCard
brief={brief}
key={`${brief.city}-${brief.date}`}
locale={locale}
readBriefLabel={copy.readBrief}
/>
))}
</section>
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>Methodology</p>
<p className={sectionTitle}>{copy.methodology}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
How the public read is produced
{copy.methodologyPanelTitle}
</h2>
<p className={`${bodyText} mt-3`}>
Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.
{copy.methodologyPanelBody}
</p>
<div className="mt-4">
<MethodologyLinks slugs={["deb", "settlement-sources"]} />
<MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]} />
</div>
</div>
<div>
<p className={sectionTitle}>Source notes</p>
<p className={sectionTitle}>{copy.sourceNotes}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
Official-source context
{copy.sourcePanelTitle}
</h2>
<p className={`${bodyText} mt-3`}>
Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.
{copy.sourcePanelBody}
</p>
<div className="mt-4">
<SourceLinks slugs={["mgm", "metar", "hko", "noaa"]} />
<SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]} />
</div>
</div>
</section>
@@ -203,25 +233,34 @@ export function BriefsIndexPageView() {
);
}
export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
export function BriefDetailPageView({
brief,
locale = "en-US",
}: {
brief: PublicBrief;
locale?: LandingLocale;
}) {
const copy = PUBLIC_CONTENT_COPY[locale];
const localizedBrief = localizeBrief(brief, locale);
return (
<div className={pageShell}>
<PublicContentAnalytics
eventType="brief_view"
onceKey={`brief:${brief.city}:${brief.date}`}
payload={{ city: brief.city, date: brief.date, source: brief.settlementSource }}
onceKey={`brief:${localizedBrief.city}:${localizedBrief.date}`}
payload={{ city: localizedBrief.city, date: localizedBrief.date, source: localizedBrief.settlementSource }}
/>
<PublicHeader />
<PublicHeader locale={locale} />
<PageIntro
description={brief.description}
eyebrow={`${brief.cityName}, ${brief.countryName} / ${brief.date}`}
title={brief.title}
description={localizedBrief.description}
eyebrow={`${localizedBrief.cityName}, ${localizedBrief.countryName} / ${localizedBrief.date}`}
title={localizedBrief.title}
/>
<div className={contentWrap}>
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.9fr]">
<article className={`${panel} p-5`}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{brief.signals.map((signal) => (
{localizedBrief.signals.map((signal) => (
<div className="rounded-md border border-slate-200 bg-slate-50 p-4" key={signal.label}>
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
<p className="mt-1 font-mono text-2xl font-black text-slate-950">{signal.value}</p>
@@ -230,30 +269,30 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
))}
</div>
<div className="mt-6 grid gap-5">
<BriefSection title="DEB read" body={brief.debRead} />
<BriefSection title="Settlement-source read" body={brief.sourceRead} />
<BriefSection title="Model context" body={brief.modelRead} />
<BriefSection title="Risk notes" body={brief.riskRead} />
<BriefSection title={copy.detailLabels.debRead} body={localizedBrief.debRead} />
<BriefSection title={copy.detailLabels.settlementSourceRead} body={localizedBrief.sourceRead} />
<BriefSection title={copy.detailLabels.modelContext} body={localizedBrief.modelRead} />
<BriefSection title={copy.detailLabels.riskNotes} body={localizedBrief.riskRead} />
</div>
</article>
<aside className={`${panel} h-fit p-5`}>
<p className={sectionTitle}>Snapshot</p>
<p className={sectionTitle}>{copy.snapshot}</p>
<dl className="mt-4 grid gap-3 text-sm">
<InfoRow label="Market" value={brief.market} />
<InfoRow label="Settlement source" value={brief.settlementSource} />
<InfoRow label="Updated" value={formatDateTime(brief.updatedAt)} />
<InfoRow label="Freshness" value={brief.dataFreshness} />
<InfoRow label={copy.market} value={localizedBrief.market} />
<InfoRow label={copy.settlementSource} value={localizedBrief.settlementSource} />
<InfoRow label={copy.updated} value={formatDateTime(localizedBrief.updatedAt, locale)} />
<InfoRow label={copy.freshness} value={localizedBrief.dataFreshness} />
</dl>
<div className="mt-5 flex flex-col gap-3">
<PublicContentCta
className={primaryButton}
href="/terminal"
payload={{ city: brief.city, date: brief.date, cta: "terminal" }}
payload={{ city: localizedBrief.city, date: localizedBrief.date, cta: "terminal" }}
>
{brief.primaryCtaLabel}
{localizedBrief.primaryCtaLabel}
</PublicContentCta>
<Link className={secondaryButton} href="/briefs">
All public briefs
{copy.allPublicBriefs}
</Link>
</div>
</aside>
@@ -261,9 +300,9 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
<div className={`${panel} p-5`}>
<p className={sectionTitle}>Checks before acting</p>
<p className={sectionTitle}>{copy.checksBeforeActing}</p>
<ul className="mt-4 grid gap-3">
{brief.checkpoints.map((checkpoint) => (
{localizedBrief.checkpoints.map((checkpoint) => (
<li className={bodyText} key={checkpoint}>
{checkpoint}
</li>
@@ -271,15 +310,15 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
</ul>
</div>
<div className={`${panel} p-5`}>
<p className={sectionTitle}>Distribution copy</p>
<p className={`${bodyText} mt-4`}>{brief.distributionText}</p>
<p className={sectionTitle}>{copy.distributionCopy}</p>
<p className={`${bodyText} mt-4`}>{localizedBrief.distributionText}</p>
<div className="mt-4">
<PublicContentOutboundLink
className={secondaryButton}
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(brief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(brief)))}`}
payload={{ city: brief.city, date: brief.date, destination: "x_intent" }}
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(localizedBrief)))}`}
payload={{ city: localizedBrief.city, date: localizedBrief.date, destination: "x_intent" }}
>
Share on X
{copy.shareOnX}
</PublicContentOutboundLink>
</div>
</div>
@@ -287,21 +326,21 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>Methodology links</p>
<p className={sectionTitle}>{copy.methodologyLinks}</p>
<div className="mt-4">
<MethodologyLinks slugs={brief.methodologySlugs} />
<MethodologyLinks locale={locale} slugs={localizedBrief.methodologySlugs} />
</div>
</div>
<div>
<p className={sectionTitle}>Source links</p>
<p className={sectionTitle}>{copy.sourceLinks}</p>
<div className="mt-4">
<SourceLinks slugs={brief.sourceSlugs} />
<SourceLinks locale={locale} slugs={localizedBrief.sourceSlugs} />
</div>
</div>
</section>
<p className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
{brief.notFinancialAdvice}
{localizedBrief.notFinancialAdvice}
</p>
</div>
</div>
@@ -453,8 +492,8 @@ export function SourceDetailPageView({ source }: { source: SourcePage }) {
);
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat("en", {
function formatDateTime(value: string, locale: LandingLocale = "en-US") {
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
@@ -59,6 +59,15 @@ export function runTests() {
content.includes("distributionText"),
"public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy",
);
assert(
content.includes("PUBLIC_CONTENT_COPY") &&
content.includes('"zh-CN"') &&
content.includes('"en-US"') &&
content.includes("公开天气市场简报") &&
content.includes("安卡拉") &&
content.includes("阅读简报"),
"public brief content must provide Chinese and English localized copy",
);
assert(
briefDetail.includes("generateStaticParams") &&
briefDetail.includes("generateMetadata") &&
@@ -77,11 +86,18 @@ export function runTests() {
"methodology and source detail routes must expose structured data for GEO/SEO",
);
assert(
publicPages.includes('MethodologyLinks slugs={["deb", "settlement-sources"]}') &&
publicPages.includes('SourceLinks slugs={["mgm", "metar", "hko", "noaa"]}') &&
publicPages.includes('MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]}') &&
publicPages.includes('SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]}') &&
briefsIndex.includes("Weather Market Brief"),
"brief index must cross-link to DEB methodology and settlement source pages",
);
assert(
publicPages.includes("LandingLocaleToggle") &&
publicPages.includes("localizeBrief") &&
publicPages.includes("PUBLIC_CONTENT_COPY") &&
publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'),
"public content pages must render the shared language toggle and localized brief copy",
);
assert(
analytics.includes('"brief_view"') &&
analytics.includes('"brief_cta_click"') &&
+323
View File
@@ -1,3 +1,5 @@
import type { LandingLocale } from "@/components/landing/landingLocale";
export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top";
export type PublicBriefSignal = {
@@ -57,6 +59,140 @@ export type SourcePage = {
relatedMethodologySlugs: string[];
};
export type PublicContentCopy = {
allPublicBriefs: string;
briefIndexDescription: string;
briefIndexEyebrow: string;
briefIndexTitle: string;
checksBeforeActing: string;
distributionCopy: string;
docs: string;
freshness: string;
market: string;
methodology: string;
methodologyIndexDescription: string;
methodologyIndexEyebrow: string;
methodologyIndexTitle: string;
methodologyLinks: string;
methodologyPanelBody: string;
methodologyPanelTitle: string;
openLiveTerminal: string;
readBrief: string;
readMethodology: string;
readSourceNote: string;
settlementSource: string;
shareOnX: string;
snapshot: string;
sourceIndexDescription: string;
sourceIndexEyebrow: string;
sourceIndexTitle: string;
sourceLinks: string;
sourceNotes: string;
sourcePanelBody: string;
sourcePanelTitle: string;
sources: string;
updated: string;
detailLabels: {
debRead: string;
modelContext: string;
riskNotes: string;
settlementSourceRead: string;
};
};
export const PUBLIC_CONTENT_COPY: Record<LandingLocale, PublicContentCopy> = {
"zh-CN": {
allPublicBriefs: "全部公开简报",
briefIndexDescription:
"公开天气市场简报把选定城市的市场读数整理为可索引证据:结算源、DEB 背景、模型分歧、新鲜度备注,以及明确的研究免责声明。",
briefIndexEyebrow: "天气市场简报",
briefIndexTitle: "公开天气市场简报",
checksBeforeActing: "行动前检查",
distributionCopy: "分发文案",
docs: "文档",
freshness: "新鲜度",
market: "市场",
methodology: "方法",
methodologyIndexDescription:
"公开方法页解释 PolyWeather 如何处理 DEB 融合、结算源优先级、新鲜度和来源校验。",
methodologyIndexEyebrow: "方法",
methodologyIndexTitle: "PolyWeather 如何读取天气市场",
methodologyLinks: "方法链接",
methodologyPanelBody:
"简报会交叉链接 DEB 方法和结算源优先级页面,让读者能审计为什么 PolyWeather 不把通用城市预报直接当作市场真实值。",
methodologyPanelTitle: "公开读数如何产生",
openLiveTerminal: "打开实时终端",
readBrief: "阅读简报",
readMethodology: "阅读方法",
readSourceNote: "阅读来源说明",
settlementSource: "结算源",
shareOnX: "分享到 X",
snapshot: "快照",
sourceIndexDescription:
"来源页把官方观测、机场观测和模型指引分开展示,方便读者审计 PolyWeather 为什么优先使用与结算相关的证据。",
sourceIndexEyebrow: "来源",
sourceIndexTitle: "用于公开审计的天气来源说明",
sourceLinks: "来源链接",
sourceNotes: "来源说明",
sourcePanelBody:
"来源页解释为什么 MGM、METAR、HKO、NOAA 和模型指引会在 PolyWeather 工作流中分开展示。",
sourcePanelTitle: "官方来源上下文",
sources: "来源",
updated: "更新",
detailLabels: {
debRead: "DEB 读数",
modelContext: "模型上下文",
riskNotes: "风险备注",
settlementSourceRead: "结算源读数",
},
},
"en-US": {
allPublicBriefs: "All public briefs",
briefIndexDescription:
"Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer.",
briefIndexEyebrow: "Weather Market Brief",
briefIndexTitle: "Public market briefs for temperature judgment",
checksBeforeActing: "Checks before acting",
distributionCopy: "Distribution copy",
docs: "Docs",
freshness: "Freshness",
market: "Market",
methodology: "Methodology",
methodologyIndexDescription:
"Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis.",
methodologyIndexEyebrow: "Methodology",
methodologyIndexTitle: "How PolyWeather reads weather markets",
methodologyLinks: "Methodology links",
methodologyPanelBody:
"Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.",
methodologyPanelTitle: "How the public read is produced",
openLiveTerminal: "Open live terminal",
readBrief: "Read brief",
readMethodology: "Read methodology",
readSourceNote: "Read source note",
settlementSource: "Settlement source",
shareOnX: "Share on X",
snapshot: "Snapshot",
sourceIndexDescription:
"Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence.",
sourceIndexEyebrow: "Sources",
sourceIndexTitle: "Weather source notes for public audit",
sourceLinks: "Source links",
sourceNotes: "Source notes",
sourcePanelBody:
"Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.",
sourcePanelTitle: "Official-source context",
sources: "Sources",
updated: "Updated",
detailLabels: {
debRead: "DEB read",
modelContext: "Model context",
riskNotes: "Risk notes",
settlementSourceRead: "Settlement-source read",
},
},
};
export const PUBLIC_BRIEFS: PublicBrief[] = [
{
city: "ankara",
@@ -384,6 +520,193 @@ export const SOURCE_PAGES: SourcePage[] = [
},
];
const BRIEF_LOCALIZATIONS: Record<string, Partial<PublicBrief>> = {
"ankara:2026-06-24": {
cityName: "安卡拉",
countryName: "土耳其",
title: "安卡拉天气市场简报 - 2026年6月24日",
description:
"安卡拉最高温公开简报,聚焦 MGM 结算源行为、DEB 融合预报背景和异常值复核。",
market: "当日最高温判断",
settlementSource: "MGM 官方站",
dataFreshness:
"静态公开快照。付费终端用户行动前应核对最新官方观测和 SSE replay 状态。",
debRead:
"DEB 将日内最高温读数压在孤立 MGM 尖峰下方,更接近已观测的官方区间。",
sourceRead:
"MGM 被视为主要结算参考。单个 27.1 C 点位在接受为新高前,需要与相邻官方读数比对。",
modelRead:
"ECMWF 在午后窗口比 DEB 融合更暖,但公开简报把官方观测置于纯模型移动之上。",
riskRead:
"主要风险是晚些时候官方更新或来源侧修正,导致公开快照后的认可高温发生变化。",
notFinancialAdvice:
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
distributionText:
"安卡拉 2026-06-24 公开天气市场简报:MGM 官方读数更支持 24.5 C 已观测高温,而非孤立 27.1 C 尖峰;DEB 仍低于异常点。非金融建议。",
primaryCtaLabel: "打开实时终端",
signals: [
{
label: "目前官方高温",
value: "24.5 C",
detail: "用于对比任何孤立更高点的官方来源值。",
},
{
label: "异常点待复核",
value: "27.1 C",
detail: "突然出现的单源值需要相邻时间验证。",
},
{
label: "DEB 公开读数",
value: "低于尖峰",
detail: "融合路径仍更接近已验证观测带。",
},
],
checkpoints: [
"检查疑似尖峰是否进入官方高温摘要。",
"在把单点视为结算相关之前,先比较相邻 MGM 观测。",
"市场关闭前查看付费终端的实时图表 patch 和来源新鲜度。",
],
},
"hong-kong:2026-06-24": {
cityName: "香港",
countryName: "香港",
title: "香港天气市场简报 - 2026年6月24日",
description:
"公开简报展示 PolyWeather 如何把 HKO/长洲/机场观测与 DEB、模型分歧放在一起解释最高温市场。",
market: "城市与机场最高温判断",
settlementSource: "HKO 官方网络",
dataFreshness:
"静态公开快照。HKO 和站点缓存刷新后,实时终端数值可能不同。",
debRead:
"到午后后段,实况观测与模型分歧趋于一致,DEB 支持较窄的高温窗口。",
sourceRead:
"解释机场单独移动前,应先把 HKO 网络观测作为需要校验的来源族。",
modelRead:
"模型分歧有限,因此来源新鲜度和站点选择比大尺度天气不确定性更重要。",
riskRead:
"主要风险是午后后段特定站点保温,或官方摘要出现较晚修订。",
notFinancialAdvice:
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
distributionText:
"香港 2026-06-24 公开天气市场简报:来源选择和 HKO 新鲜度比模型分歧更关键。非金融建议。",
primaryCtaLabel: "打开实时终端",
signals: [
{
label: "来源族",
value: "HKO",
detail: "先使用官方站上下文,再解释机场读数。",
},
{
label: "模型分歧",
value: "低",
detail: "晚间不确定性主要来自站点行为。",
},
{
label: "终端需要",
value: "新鲜度",
detail: "实时来源时间戳决定公开快照是否仍有用。",
},
],
checkpoints: [
"比较市场温度桶前先确认 HKO 站点时间戳。",
"把机场 METAR 观测与 HKO 网络读数分开解释。",
"如果公开快照已超过一个刷新周期,检查终端来源健康状态。",
],
},
"new-york:2026-06-24": {
cityName: "纽约",
countryName: "美国",
title: "纽约天气市场简报 - 2026年6月24日",
description:
"纽约温度市场公开简报,连接 METAR、NOAA 上下文、DEB 融合和日内尾段风险检查。",
market: "机场关联最高温判断",
settlementSource: "METAR 与 NOAA 站点上下文",
dataFreshness:
"静态公开快照。付费终端用户应检查当前 METAR 观测和官方摘要。",
debRead:
"DEB 用最新观测趋势校验偏暖模型指引,而不是直接追随原始模型高温。",
sourceRead:
"METAR 提供快速机场证据,NOAA 上下文帮助确认机场读数是否具代表性。",
modelRead:
"偏暖模型指引只有在和机场实况、云量/风场上下文校验后才有价值。",
riskRead:
"主要风险是日内尾段云层短暂打开,将机场观测推入更高温度桶。",
notFinancialAdvice:
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
distributionText:
"纽约 2026-06-24 公开天气市场简报:DEB 将偏暖模型指引与机场实时证据融合。非金融建议。",
primaryCtaLabel: "打开实时终端",
signals: [
{
label: "快速证据",
value: "METAR",
detail: "机场观测定义短周期读数。",
},
{
label: "复核",
value: "NOAA",
detail: "官方上下文帮助审计最终最高温解释。",
},
{
label: "DEB 立场",
value: "融合",
detail: "没有实况确认,不追随偏暖模型运行。",
},
],
checkpoints: [
"在每日高温窗口结束前观察最后两个 METAR 周期。",
"检查模型偏暖是否得到云量和风场观测支持。",
"最终解释结算前先使用官方来源上下文。",
],
},
};
const METHODOLOGY_TITLE_LOCALIZATIONS: Record<string, string> = {
deb: "DEB 预测方法",
"settlement-sources": "结算源优先级",
};
const SOURCE_TITLE_LOCALIZATIONS: Record<string, string> = {
ecmwf: "ECMWF 模型指引",
hko: "HKO 官方观测",
metar: "METAR 机场观测",
mgm: "MGM 天气来源",
noaa: "NOAA 天气上下文",
};
export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief {
if (locale === "en-US") return brief;
const localized = BRIEF_LOCALIZATIONS[`${brief.city}:${brief.date}`] || {};
return {
...brief,
...localized,
checkpoints: localized.checkpoints || brief.checkpoints,
methodologySlugs: localized.methodologySlugs || brief.methodologySlugs,
signals: localized.signals || brief.signals,
sourceSlugs: localized.sourceSlugs || brief.sourceSlugs,
};
}
export function localizeBriefs(locale: LandingLocale) {
return PUBLIC_BRIEFS.map((brief) => localizeBrief(brief, locale));
}
export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage {
if (locale === "en-US") return page;
return {
...page,
title: METHODOLOGY_TITLE_LOCALIZATIONS[page.slug] || page.title,
};
}
export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage {
if (locale === "en-US") return page;
return {
...page,
title: SOURCE_TITLE_LOCALIZATIONS[page.slug] || page.title,
};
}
export function briefPath(brief: PublicBrief) {
return `/briefs/${brief.city}/${brief.date}`;
}
+28 -13
View File
@@ -880,6 +880,7 @@ def update_daily_record(
shadow_probabilities=None,
calibration_summary=None,
hourly_error=None,
actual_is_final=True,
):
"""
保存/更新某城市某天的各个模型预报与最终实测值
@@ -986,7 +987,7 @@ def update_daily_record(
next_mu = round(mu, 2) if mu is not None else None
if (
old_actual == actual_high
old_actual == (actual_high if actual_is_final else old_actual)
and old_forecasts == merged_forecasts
and (deb_prediction is None or old_deb == deb_prediction)
and (mu is None or old_mu == next_mu)
@@ -1007,15 +1008,21 @@ def update_daily_record(
):
return
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
if old_actual is not None and actual_high is not None:
try:
actual_high = max(float(old_actual), float(actual_high))
except Exception:
pass
# Only final settlement truth may update actual_high. Intraday max_so_far is
# useful context, but treating it as settled truth poisons DEB training.
next_actual_high = old_actual
if actual_is_final:
next_actual_high = actual_high
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
if old_actual is not None and actual_high is not None:
try:
next_actual_high = max(float(old_actual), float(actual_high))
except Exception:
pass
existing["forecasts"] = merged_forecasts
existing["actual_high"] = actual_high
if actual_is_final or next_actual_high is not None:
existing["actual_high"] = next_actual_high
if deb_prediction is not None:
existing["deb_prediction"] = deb_prediction
if mu is not None:
@@ -1031,16 +1038,16 @@ def update_daily_record(
if next_hourly_error is not None:
existing["hourly_error"] = next_hourly_error
if actual_high is not None:
if actual_is_final and next_actual_high is not None:
try:
_persist_truth_record(
city_name,
date_str,
float(actual_high),
float(next_actual_high),
updated_by="runtime:update_daily_record",
reason="update_daily_record",
source_payload={
"actual_high": actual_high,
"actual_high": next_actual_high,
"deb_prediction": deb_prediction,
"mu": next_mu,
},
@@ -1161,8 +1168,12 @@ def calculate_dynamic_weight_components(
sorted_dates = sorted(city_data.keys(), reverse=True)
today_str = datetime.now().strftime("%Y-%m-%d")
available_days = sum(
1 for d in sorted_dates
if d != today_str and city_data[d].get("actual_high") is not None
1
for d in sorted_dates
if d != today_str
and city_data[d].get("actual_high") is not None
and isinstance(city_data[d].get("forecasts"), dict)
and any(v is not None for v in city_data[d].get("forecasts", {}).values())
)
# ── 改进3: 自适应 lookback — 数据多的城市用更多历史 ──
@@ -1187,6 +1198,7 @@ def calculate_dynamic_weight_components(
if actual is None:
continue
usable_day = False
for model in forecasts.keys():
if model in past_forecasts and past_forecasts[model] is not None:
try:
@@ -1194,6 +1206,7 @@ def calculate_dynamic_weight_components(
av = float(actual)
except (TypeError, ValueError):
continue
usable_day = True
# Track signed error for bias
model_biases[model] += (pv - av) # positive = model overpredicts
bias_samples[model] += 1
@@ -1208,6 +1221,8 @@ def calculate_dynamic_weight_components(
decay_weight = decay_factor ** days_used
errors[model].append((blended_error, decay_weight))
if not usable_day:
continue
days_used += 1
if days_used >= effective_lookback:
break
+1
View File
@@ -974,6 +974,7 @@ def analyze_weather_trend(
deb_prediction=_deb_to_save,
mu=mu,
probabilities=_prob_list,
actual_is_final=False,
)
except Exception:
pass
+19 -5
View File
@@ -2470,18 +2470,32 @@ class PaymentContractCheckoutService:
else:
self._require_user_wallet(user_id, from_addr)
validation_pending_reasons = {
"payment_tx_validation_failed",
"tx_not_mined",
}
try:
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
except Exception as exc:
raise PaymentCheckoutError(
400,
f"payment_tx_validation_failed: {exc}",
) from exc
if intent.payment_mode != "direct":
raise PaymentCheckoutError(
400,
f"payment_tx_validation_failed: {exc}",
) from exc
validation = {
"valid": False,
"reason": "payment_tx_validation_failed",
"detail": str(exc),
}
if not bool(validation.get("valid")):
reason = str(validation.get("reason") or "payment_tx_invalid").strip()
detail = str(validation.get("detail") or reason).strip()
message = reason if detail == reason else f"{reason}: {detail}"
raise PaymentCheckoutError(400, message)
is_pending_direct_validation = (
intent.payment_mode == "direct" and reason in validation_pending_reasons
)
if not is_pending_direct_validation:
raise PaymentCheckoutError(400, message)
now_iso = _to_iso(now)
self._rest(
+88
View File
@@ -0,0 +1,88 @@
from src.database.runtime_state import (
DailyRecordRepository,
RuntimeStateDB,
TrainingFeatureRecordRepository,
TruthRecordRepository,
)
def _wire_runtime_repos(monkeypatch, tmp_path):
from src.analysis import deb_algorithm
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
daily_repo = DailyRecordRepository(db)
truth_repo = TruthRecordRepository(db)
feature_repo = TrainingFeatureRecordRepository(db)
monkeypatch.setattr(deb_algorithm, "_daily_record_repo", daily_repo)
monkeypatch.setattr(deb_algorithm, "_truth_record_repo", truth_repo)
monkeypatch.setattr(deb_algorithm, "_training_feature_repo", feature_repo)
monkeypatch.setattr(deb_algorithm, "_history_cache", {})
monkeypatch.setattr(deb_algorithm, "_history_mtime", 0)
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "sqlite")
return db
def test_intraday_daily_record_does_not_persist_unfinal_actual_as_truth(monkeypatch, tmp_path):
from src.analysis.deb_algorithm import update_daily_record
db = _wire_runtime_repos(monkeypatch, tmp_path)
update_daily_record(
"ankara",
"2026-06-24",
{"ECMWF": 28.0, "GFS": 27.0},
24.0,
deb_prediction=27.5,
mu=27.2,
actual_is_final=False,
)
with db.connect() as conn:
daily = conn.execute(
"""
SELECT actual_high, deb_prediction, mu, payload_json
FROM daily_records_store
WHERE city = ? AND target_date = ?
""",
("ankara", "2026-06-24"),
).fetchone()
truth_count = conn.execute(
"""
SELECT count(*) AS n
FROM truth_records_store
WHERE city = ? AND target_date = ?
""",
("ankara", "2026-06-24"),
).fetchone()["n"]
assert daily is not None
assert daily["actual_high"] is None
assert daily["deb_prediction"] == 27.5
assert daily["mu"] == 27.2
assert truth_count == 0
def test_dynamic_weights_skip_actual_only_rows_when_selecting_recent_training_days(monkeypatch):
from src.analysis.deb_algorithm import calculate_dynamic_weight_components
history = {"ankara": {}}
for day in range(23, 15, -1):
history["ankara"][f"2026-06-{day:02d}"] = {"actual_high": 20.0}
history["ankara"]["2026-06-15"] = {
"actual_high": 20.0,
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
}
history["ankara"]["2026-06-14"] = {
"actual_high": 20.0,
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
}
monkeypatch.setattr("src.analysis.deb_algorithm.load_history", lambda _path: history)
components = calculate_dynamic_weight_components(
"ankara",
{"ECMWF": 20.0, "GFS": 30.0},
lookback_days=7,
)
assert components["weights"]["ECMWF"] > components["weights"]["GFS"]
assert components["prediction"] < 25.0
+70 -9
View File
@@ -416,10 +416,12 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
assert submitted["tx_hash"] == "0x" + "2" * 64
def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path):
def test_direct_submit_accepts_pending_validation_for_background_confirm(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "7" * 64
submitted = {}
transactions = []
monkeypatch.setattr(
service,
@@ -458,17 +460,76 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
return []
if method == "GET" and table == "payment_intents":
return []
raise AssertionError(f"submit must not mutate {table} before validation passes")
if method == "PATCH" and table == "payment_intents":
submitted.update(kwargs["payload"])
return [{"ok": True}]
if method == "POST" and table == "payment_transactions":
transactions.append(kwargs["payload"])
return [kwargs["payload"]]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
try:
service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
assert "tx_not_mined" in getattr(exc, "detail", "")
else:
raise AssertionError("expected tx_not_mined rejection")
result = service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
assert result["status"] == "submitted"
assert submitted["status"] == "submitted"
assert submitted["tx_hash"] == tx_hash
assert transactions[0]["status"] == "submitted"
def test_direct_submit_accepts_validation_exception_for_background_confirm(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "a" * 64
submitted = {}
monkeypatch.setattr(
service,
"get_intent",
lambda user_id, intent_id: service._serialize_intent(
{
"id": intent_id,
"plan_code": "pro_monthly",
"plan_id": 101,
"chain_id": 137,
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
"amount_units": "5000000",
"payment_mode": "direct",
"allowed_wallet": None,
"order_id_hex": "0x" + "1" * 64,
"status": "created",
"expires_at": "2099-01-01T00:00:00+00:00",
"tx_hash": None,
"metadata": {},
}
),
)
def fail_validation(*args, **kwargs):
raise RuntimeError("rpc timeout")
monkeypatch.setattr(service, "_validate_loaded_intent_tx", fail_validation)
def fake_rest(method, table, **kwargs):
if method == "GET" and table == "payment_transactions":
return []
if method == "GET" and table == "payment_intents":
return []
if method == "PATCH" and table == "payment_intents":
submitted.update(kwargs["payload"])
return [{"ok": True}]
if method == "POST" and table == "payment_transactions":
return [kwargs["payload"]]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
result = service.submit_intent_tx("user-1", "intent-direct-rpc-pending", tx_hash, "")
assert result["status"] == "submitted"
assert submitted["tx_hash"] == tx_hash
def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path):