@danceiny/gotry 0.0.1-rc.15 → 0.0.1-rc.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -13
- package/README.zh-CN.md +26 -12
- package/bin/gotry-booking-copilot.js +53 -0
- package/bin/gotry-bootstrap.js +357 -8
- package/bin/gotry-inner.js +442 -60
- package/bin/gotry-runtime-resolution.d.ts +27 -0
- package/bin/gotry-runtime-resolution.js +50 -0
- package/bin/gotry.js +1 -1
- package/cordis.gotry-patch.yml +15 -1
- package/data/airline-airports.json +39 -0
- package/dist/capabilities/agent-reach-deep.js +1 -1
- package/dist/capabilities/agent-reach.js +1 -1
- package/dist/capabilities/anything.js +1 -1
- package/dist/capabilities/artifacts.js +1 -1
- package/dist/capabilities/effect.js +293 -0
- package/dist/capabilities/fact-log.js +41 -0
- package/dist/capabilities/flyai.js +20 -6
- package/dist/capabilities/hbcli.js +43 -1
- package/dist/capabilities/incident-log.js +1 -1
- package/dist/capabilities/model-override.js +18 -0
- package/dist/capabilities/opensky.js +1 -1
- package/dist/capabilities/resilience.js +90 -0
- package/dist/capabilities/session/action-cache.js +1 -1
- package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
- package/dist/capabilities/session/adapters/meituan-local.js +1 -1
- package/dist/capabilities/session/benchmark.js +2 -1
- package/dist/capabilities/session/extension-bridge.js +358 -0
- package/dist/capabilities/session/extension-channel.js +93 -0
- package/dist/capabilities/session/extension-distribution.js +234 -0
- package/dist/capabilities/session/extract.js +1 -1
- package/dist/capabilities/session/golden-score.js +92 -0
- package/dist/capabilities/session/health-watch.js +216 -0
- package/dist/capabilities/session/read-guard.js +1 -1
- package/dist/capabilities/session/static-flight-golden.js +137 -0
- package/dist/capabilities/session/transport.js +1 -1
- package/dist/capabilities/session/wizard.js +147 -0
- package/dist/capabilities/session-consent.js +1 -1
- package/dist/capabilities/session-login.js +83 -1
- package/dist/capabilities/session-search.js +111 -2
- package/dist/capabilities/weather.js +168 -46
- package/dist/data/session-golden-20.json +25 -0
- package/dist/data/sf-golden-manifest.json +102 -0
- package/dist/data/sf-static-routes.json +91 -0
- package/dist/scripts/action-cache-tests.js +1 -1
- package/dist/scripts/agent-planning-budget-e2e.js +227 -0
- package/dist/scripts/agent-planning-budget-tests.js +173 -0
- package/dist/scripts/agent-reach-deep-tests.js +1 -1
- package/dist/scripts/agent-reach-tests.js +1 -1
- package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
- package/dist/scripts/anything-tests.js +1 -1
- package/dist/scripts/async-collect.js +1 -1
- package/dist/scripts/benchmark-environment-bridge-e2e.js +981 -0
- package/dist/scripts/benchmark-environment-bridge-tests.js +2544 -0
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +335 -0
- package/dist/scripts/booking-copilot-availability-policy-v2-tests.js +1355 -0
- package/dist/scripts/booking-copilot-bin-proof-tests.js +89 -0
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +113 -0
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +356 -0
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +557 -0
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +140 -0
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +202 -0
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +102 -0
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +374 -0
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +447 -0
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +205 -0
- package/dist/scripts/booking-copilot-server-proof-tests.js +317 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +283 -0
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +3935 -0
- package/dist/scripts/booking-saga-tests.js +1 -1
- package/dist/scripts/booking-surface-contract-proof-tests.js +393 -0
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +1174 -0
- package/dist/scripts/bootstrap-tests.js +59 -3
- package/dist/scripts/build-changelog.js +279 -0
- package/dist/scripts/changelog-tests.js +165 -0
- package/dist/scripts/companion-tests.js +1 -1
- package/dist/scripts/diff-test.js +1 -1
- package/dist/scripts/dsh-runtime-closure-tests.js +225 -0
- package/dist/scripts/dsh-runtime-closure.js +150 -0
- package/dist/scripts/effect-tests.js +474 -0
- package/dist/scripts/engine-run.js +1 -1
- package/dist/scripts/engine-tests.js +1 -1
- package/dist/scripts/evaluation-cadence-tests.js +347 -0
- package/dist/scripts/evaluation-contract-tests.js +574 -0
- package/dist/scripts/extension-distribution-cli.js +35 -0
- package/dist/scripts/extension-distribution-tests.js +341 -0
- package/dist/scripts/extension-tests.js +573 -0
- package/dist/scripts/fact-gate-tests.js +318 -0
- package/dist/scripts/flyai-tests.js +1 -1
- package/dist/scripts/hbcli-e2e-tests.js +180 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/health-watch-cli.js +51 -0
- package/dist/scripts/i18n-tests.js +1 -1
- package/dist/scripts/incident-tests.js +1 -1
- package/dist/scripts/journey-tests.js +1 -1
- package/dist/scripts/ledger-tests.js +1 -1
- package/dist/scripts/ledger-workflow-crash.js +1 -1
- package/dist/scripts/memory-capture-tests.js +1 -1
- package/dist/scripts/memory-decay-tests.js +1 -1
- package/dist/scripts/memory-metrics.js +1 -1
- package/dist/scripts/memory-value-report.js +1 -1
- package/dist/scripts/model-override-e2e.js +176 -0
- package/dist/scripts/nightly-evidence-tests.js +67 -4
- package/dist/scripts/nightly-evidence.js +59 -3
- package/dist/scripts/nudge-digest.js +1 -1
- package/dist/scripts/onboarding-tests.js +186 -0
- package/dist/scripts/opensky-check.js +1 -1
- package/dist/scripts/opensky-tests.js +1 -1
- package/dist/scripts/pnpm-dsh-closure-proof.js +20 -0
- package/dist/scripts/price-drift-tests.js +441 -0
- package/dist/scripts/price-drift-watch.js +491 -0
- package/dist/scripts/probe-poi-tests.js +1 -1
- package/dist/scripts/product-metrics.js +1 -1
- package/dist/scripts/publish-preverify.js +40 -4
- package/dist/scripts/realtime-pricing-tests.js +1 -1
- package/dist/scripts/replay-async.js +1 -1
- package/dist/scripts/replay-real.js +1 -1
- package/dist/scripts/replay.js +1 -1
- package/dist/scripts/session-attach-diagnose.js +1 -1
- package/dist/scripts/session-attach-poc.js +1 -1
- package/dist/scripts/session-benchmark.js +1 -1
- package/dist/scripts/session-extract-tests.js +1 -1
- package/dist/scripts/session-login.js +1 -1
- package/dist/scripts/session-tests.js +70 -20
- package/dist/scripts/sf-live-benchmark.js +338 -0
- package/dist/scripts/sf-live-cli-tests.js +21 -0
- package/dist/scripts/sf-soft-score-tests.js +108 -0
- package/dist/scripts/sf-summary.js +93 -0
- package/dist/scripts/skeleton-check.js +1 -1
- package/dist/scripts/skeleton-integration-test.js +1 -1
- package/dist/scripts/skills-contract-tests.js +1 -1
- package/dist/scripts/smoke-session-gate-tests.js +29 -0
- package/dist/scripts/smoke.js +154 -33
- package/dist/scripts/state-cli-tests.js +1 -1
- package/dist/scripts/state-cli.js +1 -1
- package/dist/scripts/static-golden-tests.js +299 -0
- package/dist/scripts/time-eval-tests.js +1 -1
- package/dist/scripts/travel-timeline-tests.js +1 -1
- package/dist/scripts/unified-tests.js +1 -1
- package/dist/scripts/weather-tests.js +694 -44
- package/dist/scripts/z3-race-tests.js +1 -1
- package/dist/src/artifact-gate.js +310 -0
- package/dist/src/benchmark-agent-conformance.js +370 -0
- package/dist/src/benchmark-environment-bridge.js +384 -0
- package/dist/src/benchmark-headless-child-diagnostics.js +173 -0
- package/dist/src/benchmark-tool-isolation.js +124 -0
- package/dist/src/bookable-facts.js +349 -0
- package/dist/src/booking-saga.js +1 -1
- package/dist/src/booking-surface/availability-policy-v2.js +830 -0
- package/dist/src/booking-surface/canonical-schema.js +113 -0
- package/dist/src/booking-surface/contracts-v2.js +89 -0
- package/dist/src/booking-surface/contracts.js +46 -0
- package/dist/src/booking-surface/dsh-planner.js +453 -0
- package/dist/src/booking-surface/dsh-plugin.js +93 -0
- package/dist/src/booking-surface/error-codes.js +94 -0
- package/dist/src/booking-surface/index.js +15 -0
- package/dist/src/booking-surface/profile.js +68 -0
- package/dist/src/booking-surface/runtime-v2.js +1771 -0
- package/dist/src/booking-surface/runtime.js +351 -0
- package/dist/src/booking-surface/server-v2.js +334 -0
- package/dist/src/booking-surface/server.js +302 -0
- package/dist/src/booking-surface/startup.js +159 -0
- package/dist/src/booking-surface/validation-v2.js +319 -0
- package/dist/src/booking-surface/validation.js +809 -0
- package/dist/src/bridge.js +1 -1
- package/dist/src/companions.js +1 -1
- package/dist/src/contracts.js +1 -1
- package/dist/src/dsh-llm.js +1 -1
- package/dist/src/engine.js +1 -1
- package/dist/src/evaluation-cadence.js +234 -0
- package/dist/src/evaluation-contracts.js +906 -0
- package/dist/src/i18n.js +1 -1
- package/dist/src/index.js +234 -60
- package/dist/src/journey.js +1 -1
- package/dist/src/loop.js +1 -1
- package/dist/src/memory-capture.js +1 -1
- package/dist/src/memory-decay.js +1 -1
- package/dist/src/memory-utility.js +1 -1
- package/dist/src/mock-llm.js +1 -1
- package/dist/src/model.js +1 -1
- package/dist/src/realtime-pricing.js +14 -7
- package/dist/src/slot-spec.js +1 -1
- package/dist/src/state-ledger.js +2 -1
- package/dist/src/time-anchor.js +1 -1
- package/dist/src/tool-budget.js +136 -0
- package/dist/src/tool-packet.js +1 -1
- package/dist/src/travel-slots.js +1 -1
- package/dist/src/travel-timeline.js +1 -1
- package/dist/src/unified.js +1 -1
- package/dist/src/wish-pool.js +1 -1
- package/dist/src/z3-shared.js +1 -1
- package/extension/README.md +50 -0
- package/extension/background.js +160 -0
- package/extension/content-bridge.js +41 -0
- package/extension/content-main.js +62 -0
- package/extension/manifest.json +32 -0
- package/package.json +291 -12
- package/schemas/booking.surface.v1.schema.json +927 -0
- package/schemas/booking.surface.v2.schema.json +61 -0
- package/ts/capabilities/fact-log.ts +54 -0
- package/ts/capabilities/flyai.ts +16 -3
- package/ts/capabilities/hbcli.ts +46 -0
- package/ts/capabilities/session/benchmark.ts +4 -1
- package/ts/capabilities/session/extension-bridge.ts +377 -0
- package/ts/capabilities/session/extension-channel.ts +95 -0
- package/ts/capabilities/session/extension-distribution.ts +264 -0
- package/ts/capabilities/session/golden-score.ts +139 -0
- package/ts/capabilities/session/health-watch.ts +257 -0
- package/ts/capabilities/session/static-flight-golden.ts +209 -0
- package/ts/capabilities/session/wizard.ts +140 -0
- package/ts/capabilities/session-login.ts +63 -2
- package/ts/capabilities/session-search.ts +105 -3
- package/ts/capabilities/weather.ts +141 -52
- package/ts/package.json +3 -3
- package/ts/src/artifact-gate.ts +365 -0
- package/ts/src/benchmark-agent-conformance.ts +448 -0
- package/ts/src/benchmark-environment-bridge.ts +348 -0
- package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
- package/ts/src/benchmark-tool-isolation.ts +166 -0
- package/ts/src/bookable-facts.ts +557 -0
- package/ts/src/booking-surface/availability-policy-v2.ts +523 -0
- package/ts/src/booking-surface/canonical-schema.js +113 -0
- package/ts/src/booking-surface/contracts-v2.ts +118 -0
- package/ts/src/booking-surface/contracts.ts +380 -0
- package/ts/src/booking-surface/dsh-planner.ts +452 -0
- package/ts/src/booking-surface/dsh-plugin.js +93 -0
- package/ts/src/booking-surface/error-codes.ts +101 -0
- package/ts/src/booking-surface/index.ts +12 -0
- package/ts/src/booking-surface/profile.ts +42 -0
- package/ts/src/booking-surface/runtime-v2.ts +1466 -0
- package/ts/src/booking-surface/runtime.ts +483 -0
- package/ts/src/booking-surface/server-v2.ts +247 -0
- package/ts/src/booking-surface/server.ts +324 -0
- package/ts/src/booking-surface/startup.ts +196 -0
- package/ts/src/booking-surface/validation-v2.ts +205 -0
- package/ts/src/booking-surface/validation.ts +453 -0
- package/ts/src/index.ts +161 -14
- package/ts/src/state-ledger.ts +1 -0
- package/ts/src/tool-budget.ts +165 -0
|
@@ -1,55 +1,705 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.js';
|
|
3
|
+
const jsonResponse = (body, status = 200)=>new Response(JSON.stringify(body), {
|
|
4
|
+
status,
|
|
5
|
+
headers: {
|
|
6
|
+
'content-type': 'application/json'
|
|
7
|
+
}
|
|
8
|
+
});
|
|
9
|
+
const geoDali = {
|
|
10
|
+
results: [
|
|
11
|
+
{
|
|
12
|
+
name: '大理市',
|
|
13
|
+
latitude: 25.6,
|
|
14
|
+
longitude: 100.2,
|
|
15
|
+
country: '中国',
|
|
16
|
+
admin1: '云南省',
|
|
17
|
+
population: 600000,
|
|
18
|
+
feature_code: 'PPLA'
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
};
|
|
22
|
+
const geoPhuket = [
|
|
23
|
+
{
|
|
24
|
+
name: 'Phuket',
|
|
25
|
+
lat: '7.88',
|
|
26
|
+
lon: '98.39',
|
|
27
|
+
address: {
|
|
28
|
+
country: '泰国',
|
|
29
|
+
province: '普吉府'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
const forecastBody = (days)=>({
|
|
34
|
+
timezone: 'Asia/Shanghai',
|
|
35
|
+
daily: {
|
|
36
|
+
time: Array.from({
|
|
37
|
+
length: days
|
|
38
|
+
}, (_, i)=>`2026-09-${String(i + 1).padStart(2, '0')}`),
|
|
39
|
+
temperature_2m_max: Array.from({
|
|
40
|
+
length: days
|
|
41
|
+
}, ()=>30),
|
|
42
|
+
temperature_2m_min: Array.from({
|
|
43
|
+
length: days
|
|
44
|
+
}, ()=>21),
|
|
45
|
+
precipitation_probability_max: Array.from({
|
|
46
|
+
length: days
|
|
47
|
+
}, ()=>60),
|
|
48
|
+
weathercode: Array.from({
|
|
49
|
+
length: days
|
|
50
|
+
}, ()=>61)
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const climateBodyFor = (year, month)=>{
|
|
54
|
+
const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
55
|
+
const monthText = String(month).padStart(2, '0');
|
|
56
|
+
return {
|
|
57
|
+
daily: {
|
|
58
|
+
time: Array.from({
|
|
59
|
+
length: days
|
|
60
|
+
}, (_, i)=>`${year}-${monthText}-${String(i + 1).padStart(2, '0')}`),
|
|
61
|
+
temperature_2m_max: Array(days).fill(29),
|
|
62
|
+
temperature_2m_min: Array(days).fill(21),
|
|
63
|
+
weathercode: Array(days).fill(2)
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
const climateBody = climateBodyFor(2025, 8);
|
|
68
|
+
const mockFetch = (handler)=>async (input, init)=>jsonResponse(await handler(String(input), init?.signal ?? new AbortController().signal));
|
|
3
69
|
const geo = await geocodePlace('大理市', {
|
|
4
|
-
count: 10
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
assert.
|
|
8
|
-
|
|
9
|
-
assert.ok(Math.abs(
|
|
10
|
-
assert.match(geo.evidence, /open-meteo-geo@2
|
|
11
|
-
console.log(
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
70
|
+
count: 10,
|
|
71
|
+
fetchImpl: mockFetch(()=>geoDali)
|
|
72
|
+
});
|
|
73
|
+
assert.equal(geo.ok, true);
|
|
74
|
+
assert.equal(geo.via, 'open-meteo');
|
|
75
|
+
assert.ok(Math.abs(geo.results[0].latitude - 25.6) < 0.5);
|
|
76
|
+
assert.match(geo.evidence, /open-meteo-geo@2/);
|
|
77
|
+
console.log('1. geocode 主源命中 + alias/schema OK');
|
|
78
|
+
const seenRequests = [];
|
|
79
|
+
const phuket = await geocodePlace('普吉岛', {
|
|
80
|
+
count: 3,
|
|
81
|
+
fetchImpl: async (input, init)=>{
|
|
82
|
+
seenRequests.push({
|
|
83
|
+
url: String(input),
|
|
84
|
+
headers: new Headers(init?.headers)
|
|
85
|
+
});
|
|
86
|
+
return jsonResponse(String(input).includes('nominatim') ? geoPhuket : {
|
|
87
|
+
results: []
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
assert.equal(phuket.ok, true);
|
|
92
|
+
assert.equal(phuket.via, 'nominatim');
|
|
93
|
+
assert.match(phuket.results[0].country ?? '', /泰国/);
|
|
94
|
+
assert.ok(Math.abs(phuket.results[0].latitude - 8) < 0.5);
|
|
95
|
+
assert.match(seenRequests[0].url, /name=%E6%99%AE%E5%90%89%E5%B2%9B.*count=3.*language=zh/);
|
|
96
|
+
assert.match(seenRequests[1].url, /q=%E6%99%AE%E5%90%89%E5%B2%9B.*limit=3.*accept-language=zh/);
|
|
97
|
+
assert.match(seenRequests[1].headers.get('user-agent') ?? '', /gotry-travel-agent/);
|
|
98
|
+
const badAddress = await geocodePlace('坏地址', {
|
|
99
|
+
fetchImpl: mockFetch((url)=>url.includes('nominatim') ? [
|
|
100
|
+
{
|
|
101
|
+
name: 'X',
|
|
102
|
+
lat: '1',
|
|
103
|
+
lon: '2',
|
|
104
|
+
address: {
|
|
105
|
+
country: {},
|
|
106
|
+
province: 7
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
] : {
|
|
110
|
+
results: []
|
|
111
|
+
})
|
|
112
|
+
});
|
|
113
|
+
assert.equal(badAddress.ok, true);
|
|
114
|
+
assert.equal('country' in badAddress.results[0], false);
|
|
115
|
+
assert.equal('admin1' in badAddress.results[0], false);
|
|
116
|
+
console.log('2. geocode 兜底链 + 地名 alias OK');
|
|
19
117
|
const fc = await getForecast({
|
|
20
|
-
latitude:
|
|
21
|
-
longitude:
|
|
118
|
+
latitude: 25.6,
|
|
119
|
+
longitude: 100.2
|
|
120
|
+
}, {
|
|
121
|
+
days: 7,
|
|
122
|
+
fetchImpl: mockFetch(()=>forecastBody(7))
|
|
123
|
+
});
|
|
124
|
+
assert.equal(fc.ok, true);
|
|
125
|
+
assert.equal(fc.daily?.length, 7);
|
|
126
|
+
assert.equal(fc.daily?.[0]?.weatherCode, 61);
|
|
127
|
+
assert.ok(fc.latencyMs < 1000);
|
|
128
|
+
console.log('3. forecast schema/行为 OK');
|
|
129
|
+
const slow = mockFetch((_url, signal)=>new Promise((_, reject)=>{
|
|
130
|
+
const abort = ()=>reject(new Error('This operation was aborted'));
|
|
131
|
+
if (signal.aborted) abort();
|
|
132
|
+
else signal.addEventListener('abort', abort, {
|
|
133
|
+
once: true
|
|
134
|
+
});
|
|
135
|
+
setTimeout(()=>reject(new Error('late response')), 250);
|
|
136
|
+
}));
|
|
137
|
+
const slowStarted = Date.now();
|
|
138
|
+
const slowFc = await getForecast({
|
|
139
|
+
latitude: 25.6,
|
|
140
|
+
longitude: 100.2
|
|
141
|
+
}, {
|
|
142
|
+
timeoutMs: 25,
|
|
143
|
+
fetchImpl: slow
|
|
144
|
+
});
|
|
145
|
+
assert.equal(slowFc.ok, false);
|
|
146
|
+
assert.ok(Date.now() - slowStarted < 150, 'slow forecast must respect timeout');
|
|
147
|
+
assert.match(slowFc.evidence, /error/);
|
|
148
|
+
const never = mockFetch(()=>new Promise(()=>undefined));
|
|
149
|
+
const neverStarted = Date.now();
|
|
150
|
+
const neverFc = await getForecast({
|
|
151
|
+
latitude: 25.6,
|
|
152
|
+
longitude: 100.2
|
|
153
|
+
}, {
|
|
154
|
+
timeoutMs: 20,
|
|
155
|
+
fetchImpl: never
|
|
156
|
+
});
|
|
157
|
+
assert.equal(neverFc.ok, false);
|
|
158
|
+
assert.ok(Date.now() - neverStarted < 150, 'hard deadline must cover a fetch that ignores abort');
|
|
159
|
+
const neverBody = await getForecast({
|
|
160
|
+
latitude: 25.6,
|
|
161
|
+
longitude: 100.2
|
|
162
|
+
}, {
|
|
163
|
+
timeoutMs: 20,
|
|
164
|
+
fetchImpl: async ()=>({
|
|
165
|
+
ok: true,
|
|
166
|
+
json: ()=>new Promise(()=>undefined)
|
|
167
|
+
})
|
|
168
|
+
});
|
|
169
|
+
assert.equal(neverBody.ok, false, 'hard deadline must cover a body decoder that never settles');
|
|
170
|
+
let lateResolve;
|
|
171
|
+
const lateResolveResult = await getForecast({
|
|
172
|
+
latitude: 25.6,
|
|
173
|
+
longitude: 100.2
|
|
174
|
+
}, {
|
|
175
|
+
timeoutMs: 10,
|
|
176
|
+
fetchImpl: async ()=>({
|
|
177
|
+
ok: true,
|
|
178
|
+
json: ()=>new Promise((resolve)=>{
|
|
179
|
+
lateResolve = resolve;
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
});
|
|
183
|
+
assert.equal(lateResolveResult.ok, false);
|
|
184
|
+
lateResolve?.(forecastBody(7));
|
|
185
|
+
let lateReject;
|
|
186
|
+
const lateRejectResult = await getForecast({
|
|
187
|
+
latitude: 25.6,
|
|
188
|
+
longitude: 100.2
|
|
189
|
+
}, {
|
|
190
|
+
timeoutMs: 10,
|
|
191
|
+
fetchImpl: async ()=>({
|
|
192
|
+
ok: true,
|
|
193
|
+
json: ()=>new Promise((_, reject)=>{
|
|
194
|
+
lateReject = reject;
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
});
|
|
198
|
+
assert.equal(lateRejectResult.ok, false);
|
|
199
|
+
lateReject?.(Object.create(null));
|
|
200
|
+
await new Promise((resolve)=>setImmediate(resolve));
|
|
201
|
+
console.log('4. slow forecast timeout + graceful fallback OK');
|
|
202
|
+
let calls = 0;
|
|
203
|
+
const slowBoth = mockFetch((_url, signal)=>{
|
|
204
|
+
calls += 1;
|
|
205
|
+
return new Promise((_, reject)=>{
|
|
206
|
+
const abort = ()=>reject(new Error('network timeout'));
|
|
207
|
+
if (signal.aborted) abort();
|
|
208
|
+
else signal.addEventListener('abort', abort, {
|
|
209
|
+
once: true
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
const chainStarted = Date.now();
|
|
214
|
+
const chain = await geocodePlace('不存在的地方', {
|
|
215
|
+
timeoutMs: 35,
|
|
216
|
+
fetchImpl: slowBoth
|
|
217
|
+
});
|
|
218
|
+
assert.equal(chain.ok, false);
|
|
219
|
+
assert.equal(calls, 1, 'an exhausted primary budget must not start fallback');
|
|
220
|
+
assert.ok(Date.now() - chainStarted < 150, 'fallback chain must share one total budget');
|
|
221
|
+
assert.match(chain.evidence, /open-meteo-geo@error/);
|
|
222
|
+
let fastPrimaryCalls = 0;
|
|
223
|
+
let fastFallbackCalls = 0;
|
|
224
|
+
const fallbackAfterFastFailure = await geocodePlace('不存在的地方', {
|
|
225
|
+
timeoutMs: 100,
|
|
226
|
+
fetchImpl: async (input, init)=>{
|
|
227
|
+
if (String(input).includes('nominatim')) {
|
|
228
|
+
fastFallbackCalls += 1;
|
|
229
|
+
return jsonResponse([]);
|
|
230
|
+
}
|
|
231
|
+
fastPrimaryCalls += 1;
|
|
232
|
+
throw new Error('ECONNRESET');
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
assert.equal(fallbackAfterFastFailure.ok, false);
|
|
236
|
+
assert.equal(fastPrimaryCalls, 1, 'primary should be attempted once');
|
|
237
|
+
assert.equal(fastFallbackCalls, 1, 'a fast primary failure should still attempt fallback once');
|
|
238
|
+
const network = await getForecast({
|
|
239
|
+
latitude: 25.6,
|
|
240
|
+
longitude: 100.2
|
|
22
241
|
}, {
|
|
23
|
-
|
|
242
|
+
fetchImpl: mockFetch(()=>{
|
|
243
|
+
throw new Error('ECONNRESET');
|
|
244
|
+
})
|
|
245
|
+
});
|
|
246
|
+
assert.equal(network.ok, false);
|
|
247
|
+
assert.match(network.error ?? '', /ECONNRESET/);
|
|
248
|
+
const httpError = await getForecast({
|
|
249
|
+
latitude: 25.6,
|
|
250
|
+
longitude: 100.2
|
|
251
|
+
}, {
|
|
252
|
+
fetchImpl: async ()=>jsonResponse({}, 503)
|
|
253
|
+
});
|
|
254
|
+
assert.equal(httpError.ok, false);
|
|
255
|
+
assert.match(httpError.error ?? '', /HTTP 503/);
|
|
256
|
+
const malformedGeo = await geocodePlace('坏响应', {
|
|
257
|
+
timeoutMs: 100,
|
|
258
|
+
fetchImpl: mockFetch((url)=>url.includes('nominatim') ? {} : {
|
|
259
|
+
results: {}
|
|
260
|
+
})
|
|
24
261
|
});
|
|
25
|
-
assert.equal(
|
|
26
|
-
|
|
27
|
-
assert.ok(fc.daily[0].tempMaxC > -50 && fc.daily[0].tempMaxC < 60, '温度合理范围');
|
|
28
|
-
assert.match(fc.evidence, /\[实时API:open-meteo@2/, '证据链');
|
|
29
|
-
assert.ok(fc.latencyMs < 5000, `延迟应 <5s,实际 ${fc.latencyMs}ms`);
|
|
30
|
-
console.log(`2. forecast 大理 7 天:${fc.daily[0].tempMinC}–${fc.daily[0].tempMaxC}°C ${wmoLabel(fc.daily[0].weatherCode)} OK`);
|
|
262
|
+
assert.equal(malformedGeo.ok, false);
|
|
263
|
+
console.log('5. slow geocode + strict fallback budget + network/HTTP errors OK');
|
|
31
264
|
const cl = await getClimate({
|
|
32
|
-
latitude:
|
|
33
|
-
longitude:
|
|
34
|
-
}, 8
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
265
|
+
latitude: 25.6,
|
|
266
|
+
longitude: 100.2
|
|
267
|
+
}, 8, {
|
|
268
|
+
year: 2025,
|
|
269
|
+
fetchImpl: mockFetch(()=>climateBody)
|
|
270
|
+
});
|
|
271
|
+
assert.equal(cl.ok, true);
|
|
272
|
+
assert.equal(cl.daily?.length, 31);
|
|
273
|
+
const malformed = await getForecast({
|
|
274
|
+
latitude: 25.6,
|
|
275
|
+
longitude: 100.2
|
|
276
|
+
}, {
|
|
277
|
+
days: 7,
|
|
278
|
+
fetchImpl: mockFetch(()=>({
|
|
279
|
+
daily: {
|
|
280
|
+
time: []
|
|
281
|
+
}
|
|
282
|
+
}))
|
|
283
|
+
});
|
|
284
|
+
assert.equal(malformed.ok, false);
|
|
285
|
+
const malformedElement = await getForecast({
|
|
286
|
+
latitude: 25.6,
|
|
287
|
+
longitude: 100.2
|
|
288
|
+
}, {
|
|
289
|
+
days: 7,
|
|
290
|
+
fetchImpl: mockFetch(()=>({
|
|
291
|
+
daily: {
|
|
292
|
+
...forecastBody(7).daily,
|
|
293
|
+
temperature_2m_max: [
|
|
294
|
+
30,
|
|
295
|
+
'bad',
|
|
296
|
+
30,
|
|
297
|
+
30,
|
|
298
|
+
30,
|
|
299
|
+
30,
|
|
300
|
+
30
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
}))
|
|
304
|
+
});
|
|
305
|
+
assert.equal(malformedElement.ok, false);
|
|
306
|
+
const wrongPrecip = await getForecast({
|
|
307
|
+
latitude: 25.6,
|
|
308
|
+
longitude: 100.2
|
|
309
|
+
}, {
|
|
310
|
+
days: 7,
|
|
311
|
+
fetchImpl: mockFetch(()=>({
|
|
312
|
+
daily: {
|
|
313
|
+
...forecastBody(7).daily,
|
|
314
|
+
precipitation_probability_max: 'bad'
|
|
315
|
+
}
|
|
316
|
+
}))
|
|
317
|
+
});
|
|
318
|
+
assert.equal(wrongPrecip.ok, false);
|
|
319
|
+
const wrongPrecipObject = await getForecast({
|
|
320
|
+
latitude: 25.6,
|
|
321
|
+
longitude: 100.2
|
|
322
|
+
}, {
|
|
323
|
+
days: 7,
|
|
324
|
+
fetchImpl: mockFetch(()=>({
|
|
325
|
+
daily: {
|
|
326
|
+
...forecastBody(7).daily,
|
|
327
|
+
precipitation_probability_max: {}
|
|
328
|
+
}
|
|
329
|
+
}))
|
|
330
|
+
});
|
|
331
|
+
assert.equal(wrongPrecipObject.ok, false);
|
|
332
|
+
const absentPrecip = {
|
|
333
|
+
daily: {
|
|
334
|
+
...forecastBody(7).daily
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
delete absentPrecip.daily.precipitation_probability_max;
|
|
338
|
+
const forecastAbsentPrecip = await getForecast({
|
|
339
|
+
latitude: 25.6,
|
|
340
|
+
longitude: 100.2
|
|
341
|
+
}, {
|
|
342
|
+
fetchImpl: mockFetch(()=>absentPrecip)
|
|
343
|
+
});
|
|
344
|
+
assert.equal(forecastAbsentPrecip.ok, true);
|
|
345
|
+
assert.equal(forecastAbsentPrecip.daily?.[0]?.precipProbMaxPct, null);
|
|
346
|
+
const forecastNullPrecip = await getForecast({
|
|
347
|
+
latitude: 25.6,
|
|
348
|
+
longitude: 100.2
|
|
349
|
+
}, {
|
|
350
|
+
fetchImpl: mockFetch(()=>({
|
|
351
|
+
daily: {
|
|
352
|
+
...forecastBody(7).daily,
|
|
353
|
+
precipitation_probability_max: null
|
|
354
|
+
}
|
|
355
|
+
}))
|
|
356
|
+
});
|
|
357
|
+
assert.equal(forecastNullPrecip.ok, true);
|
|
358
|
+
assert.equal(forecastNullPrecip.daily?.[0]?.precipProbMaxPct, null);
|
|
359
|
+
const forecastValidPrecip = await getForecast({
|
|
360
|
+
latitude: 25.6,
|
|
361
|
+
longitude: 100.2
|
|
362
|
+
}, {
|
|
363
|
+
fetchImpl: mockFetch(()=>forecastBody(7))
|
|
364
|
+
});
|
|
365
|
+
assert.equal(forecastValidPrecip.ok, true);
|
|
366
|
+
assert.equal(forecastValidPrecip.daily?.[0]?.precipProbMaxPct, 60);
|
|
367
|
+
const nullClimate = await getClimate({
|
|
368
|
+
latitude: 25.6,
|
|
369
|
+
longitude: 100.2
|
|
370
|
+
}, 8, {
|
|
371
|
+
fetchImpl: mockFetch(()=>null)
|
|
372
|
+
});
|
|
373
|
+
assert.equal(nullClimate.ok, false);
|
|
374
|
+
const slowClimate = await getClimate({
|
|
375
|
+
latitude: 25.6,
|
|
376
|
+
longitude: 100.2
|
|
377
|
+
}, 8, {
|
|
378
|
+
timeoutMs: 20,
|
|
379
|
+
fetchImpl: slow
|
|
380
|
+
});
|
|
381
|
+
assert.equal(slowClimate.ok, false);
|
|
382
|
+
const shortClimate = await getClimate({
|
|
383
|
+
latitude: 25.6,
|
|
384
|
+
longitude: 100.2
|
|
385
|
+
}, 8, {
|
|
386
|
+
fetchImpl: mockFetch(()=>({
|
|
387
|
+
daily: {
|
|
388
|
+
...climateBody.daily,
|
|
389
|
+
time: [
|
|
390
|
+
'2025-08-01'
|
|
391
|
+
],
|
|
392
|
+
temperature_2m_max: [
|
|
393
|
+
29
|
|
394
|
+
],
|
|
395
|
+
temperature_2m_min: [
|
|
396
|
+
21
|
|
397
|
+
],
|
|
398
|
+
weathercode: [
|
|
399
|
+
2
|
|
400
|
+
]
|
|
401
|
+
}
|
|
402
|
+
}))
|
|
403
|
+
});
|
|
404
|
+
assert.equal(shortClimate.ok, false);
|
|
405
|
+
for (const badDays of [
|
|
406
|
+
0,
|
|
407
|
+
Number.NaN,
|
|
408
|
+
2.5
|
|
409
|
+
]){
|
|
410
|
+
let fetches = 0;
|
|
411
|
+
const invalid = await getForecast({
|
|
412
|
+
latitude: 25.6,
|
|
413
|
+
longitude: 100.2
|
|
414
|
+
}, {
|
|
415
|
+
days: badDays,
|
|
416
|
+
fetchImpl: mockFetch(()=>{
|
|
417
|
+
fetches += 1;
|
|
418
|
+
return forecastBody(7);
|
|
419
|
+
})
|
|
420
|
+
});
|
|
421
|
+
assert.equal(invalid.ok, false);
|
|
422
|
+
assert.equal(fetches, 0);
|
|
423
|
+
}
|
|
424
|
+
for (const badTimeout of [
|
|
425
|
+
0,
|
|
426
|
+
-1,
|
|
427
|
+
Number.NaN,
|
|
428
|
+
Number.POSITIVE_INFINITY,
|
|
429
|
+
2.5
|
|
430
|
+
]){
|
|
431
|
+
let forecastFetches = 0;
|
|
432
|
+
const invalidForecast = await getForecast({
|
|
433
|
+
latitude: 25.6,
|
|
434
|
+
longitude: 100.2
|
|
435
|
+
}, {
|
|
436
|
+
timeoutMs: badTimeout,
|
|
437
|
+
fetchImpl: mockFetch(()=>{
|
|
438
|
+
forecastFetches += 1;
|
|
439
|
+
return forecastBody(7);
|
|
440
|
+
})
|
|
441
|
+
});
|
|
442
|
+
assert.equal(invalidForecast.ok, false);
|
|
443
|
+
assert.equal(forecastFetches, 0, `invalid forecast timeout ${String(badTimeout)} must not fetch`);
|
|
444
|
+
let climateFetches = 0;
|
|
445
|
+
const invalidClimateTimeout = await getClimate({
|
|
446
|
+
latitude: 25.6,
|
|
447
|
+
longitude: 100.2
|
|
448
|
+
}, 8, {
|
|
449
|
+
timeoutMs: badTimeout,
|
|
450
|
+
fetchImpl: mockFetch(()=>{
|
|
451
|
+
climateFetches += 1;
|
|
452
|
+
return climateBody;
|
|
453
|
+
})
|
|
454
|
+
});
|
|
455
|
+
assert.equal(invalidClimateTimeout.ok, false);
|
|
456
|
+
assert.equal(climateFetches, 0, `invalid climate timeout ${String(badTimeout)} must not fetch`);
|
|
457
|
+
let geoFetches = 0;
|
|
458
|
+
const invalidGeoTimeout = await geocodePlace('大理市', {
|
|
459
|
+
timeoutMs: badTimeout,
|
|
460
|
+
fetchImpl: mockFetch(()=>{
|
|
461
|
+
geoFetches += 1;
|
|
462
|
+
return geoDali;
|
|
463
|
+
})
|
|
464
|
+
});
|
|
465
|
+
assert.equal(invalidGeoTimeout.ok, false);
|
|
466
|
+
assert.equal(geoFetches, 0, `invalid geocode timeout ${String(badTimeout)} must not fetch`);
|
|
467
|
+
}
|
|
468
|
+
let cappedDays = '';
|
|
469
|
+
const capped = await getForecast({
|
|
470
|
+
latitude: 25.6,
|
|
471
|
+
longitude: 100.2
|
|
472
|
+
}, {
|
|
473
|
+
days: 99,
|
|
474
|
+
fetchImpl: async (input)=>{
|
|
475
|
+
cappedDays = String(input);
|
|
476
|
+
return jsonResponse(forecastBody(16));
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
assert.equal(capped.ok, true);
|
|
480
|
+
assert.match(cappedDays, /forecast_days=16/);
|
|
481
|
+
const wrongMonth = await getClimate({
|
|
482
|
+
latitude: 25.6,
|
|
483
|
+
longitude: 100.2
|
|
484
|
+
}, 9, {
|
|
485
|
+
year: 2025,
|
|
486
|
+
fetchImpl: mockFetch(()=>climateBody)
|
|
487
|
+
});
|
|
488
|
+
assert.equal(wrongMonth.ok, false);
|
|
489
|
+
const leapBody = {
|
|
490
|
+
daily: {
|
|
491
|
+
time: Array.from({
|
|
492
|
+
length: 29
|
|
493
|
+
}, (_, i)=>`2024-02-${String(i + 1).padStart(2, '0')}`),
|
|
494
|
+
temperature_2m_max: Array(29).fill(10),
|
|
495
|
+
temperature_2m_min: Array(29).fill(1),
|
|
496
|
+
weathercode: Array(29).fill(0)
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const leap = await getClimate({
|
|
500
|
+
latitude: 25.6,
|
|
501
|
+
longitude: 100.2
|
|
502
|
+
}, 2, {
|
|
503
|
+
year: 2024,
|
|
504
|
+
fetchImpl: mockFetch(()=>leapBody)
|
|
505
|
+
});
|
|
506
|
+
assert.equal(leap.ok, true);
|
|
507
|
+
const historicalMinimum = await getClimate({
|
|
508
|
+
latitude: 25.6,
|
|
509
|
+
longitude: 100.2
|
|
510
|
+
}, 8, {
|
|
511
|
+
year: 1940,
|
|
512
|
+
fetchImpl: mockFetch(()=>climateBodyFor(1940, 8))
|
|
513
|
+
});
|
|
514
|
+
assert.equal(historicalMinimum.ok, true);
|
|
515
|
+
const currentYear = new Date().getUTCFullYear();
|
|
516
|
+
const currentClimate = await getClimate({
|
|
517
|
+
latitude: 25.6,
|
|
518
|
+
longitude: 100.2
|
|
519
|
+
}, 8, {
|
|
520
|
+
year: currentYear,
|
|
521
|
+
fetchImpl: mockFetch(()=>climateBodyFor(currentYear, 8))
|
|
522
|
+
});
|
|
523
|
+
assert.equal(currentClimate.ok, true);
|
|
524
|
+
for (const badYear of [
|
|
525
|
+
1939,
|
|
526
|
+
2025.5,
|
|
527
|
+
currentYear + 1
|
|
528
|
+
]){
|
|
529
|
+
let yearFetches = 0;
|
|
530
|
+
const invalidYear = await getClimate({
|
|
531
|
+
latitude: 25.6,
|
|
532
|
+
longitude: 100.2
|
|
533
|
+
}, 8, {
|
|
534
|
+
year: badYear,
|
|
535
|
+
fetchImpl: mockFetch(()=>{
|
|
536
|
+
yearFetches += 1;
|
|
537
|
+
return climateBody;
|
|
538
|
+
})
|
|
539
|
+
});
|
|
540
|
+
assert.equal(invalidYear.ok, false);
|
|
541
|
+
assert.equal(yearFetches, 0, `invalid climate year ${String(badYear)} must not fetch`);
|
|
542
|
+
}
|
|
543
|
+
const duplicateDayBody = climateBodyFor(2025, 8);
|
|
544
|
+
duplicateDayBody.daily.time[1] = duplicateDayBody.daily.time[0];
|
|
545
|
+
const duplicateDay = await getClimate({
|
|
546
|
+
latitude: 25.6,
|
|
547
|
+
longitude: 100.2
|
|
548
|
+
}, 8, {
|
|
549
|
+
year: 2025,
|
|
550
|
+
fetchImpl: mockFetch(()=>duplicateDayBody)
|
|
551
|
+
});
|
|
552
|
+
assert.equal(duplicateDay.ok, false);
|
|
553
|
+
const day99Body = climateBodyFor(2025, 8);
|
|
554
|
+
day99Body.daily.time[0] = '2025-08-99';
|
|
555
|
+
const day99 = await getClimate({
|
|
556
|
+
latitude: 25.6,
|
|
557
|
+
longitude: 100.2
|
|
558
|
+
}, 8, {
|
|
559
|
+
year: 2025,
|
|
560
|
+
fetchImpl: mockFetch(()=>day99Body)
|
|
561
|
+
});
|
|
562
|
+
assert.equal(day99.ok, false);
|
|
563
|
+
let invalidClimateFetches = 0;
|
|
564
|
+
const invalidClimate = await getClimate({
|
|
565
|
+
latitude: 25.6,
|
|
566
|
+
longitude: 100.2
|
|
567
|
+
}, 13, {
|
|
568
|
+
year: 2025,
|
|
569
|
+
fetchImpl: mockFetch(()=>{
|
|
570
|
+
invalidClimateFetches += 1;
|
|
571
|
+
return climateBody;
|
|
572
|
+
})
|
|
573
|
+
});
|
|
574
|
+
assert.equal(invalidClimate.ok, false);
|
|
575
|
+
assert.equal(invalidClimateFetches, 0);
|
|
576
|
+
let geoZeroFetches = 0;
|
|
577
|
+
const geoZero = await geocodePlace('大理市', {
|
|
578
|
+
timeoutMs: 0,
|
|
579
|
+
fetchImpl: mockFetch(()=>{
|
|
580
|
+
geoZeroFetches += 1;
|
|
581
|
+
return geoDali;
|
|
582
|
+
})
|
|
583
|
+
});
|
|
584
|
+
assert.equal(geoZero.ok, false);
|
|
585
|
+
assert.equal(geoZeroFetches, 0);
|
|
586
|
+
let climateZeroFetches = 0;
|
|
587
|
+
const climateZero = await getClimate({
|
|
588
|
+
latitude: 25.6,
|
|
589
|
+
longitude: 100.2
|
|
590
|
+
}, 8, {
|
|
591
|
+
timeoutMs: 0,
|
|
592
|
+
fetchImpl: mockFetch(()=>{
|
|
593
|
+
climateZeroFetches += 1;
|
|
594
|
+
return climateBody;
|
|
595
|
+
})
|
|
596
|
+
});
|
|
597
|
+
assert.equal(climateZero.ok, false);
|
|
598
|
+
assert.equal(climateZeroFetches, 0);
|
|
599
|
+
let primaryResolutionFetches = 0;
|
|
600
|
+
const originalPerformanceNow = performance.now;
|
|
601
|
+
Object.defineProperty(performance, 'now', {
|
|
602
|
+
configurable: true,
|
|
603
|
+
value: (()=>{
|
|
604
|
+
const values = [
|
|
605
|
+
100,
|
|
606
|
+
104.5,
|
|
607
|
+
104.5
|
|
608
|
+
];
|
|
609
|
+
return ()=>values.shift() ?? 104.5;
|
|
610
|
+
})()
|
|
611
|
+
});
|
|
612
|
+
try {
|
|
613
|
+
const primaryResolution = await geocodePlace('剩余不足一毫秒', {
|
|
614
|
+
timeoutMs: 5,
|
|
615
|
+
fetchImpl: mockFetch(()=>{
|
|
616
|
+
primaryResolutionFetches += 1;
|
|
617
|
+
return geoDali;
|
|
618
|
+
})
|
|
619
|
+
});
|
|
620
|
+
assert.equal(primaryResolution.ok, false);
|
|
621
|
+
assert.equal(primaryResolutionFetches, 0, 'sub-millisecond primary budget must not fetch');
|
|
622
|
+
} finally{
|
|
623
|
+
Object.defineProperty(performance, 'now', {
|
|
624
|
+
configurable: true,
|
|
625
|
+
value: originalPerformanceNow
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
let primaryBudgetFetches = 0;
|
|
629
|
+
let fallbackResolutionFetches = 0;
|
|
630
|
+
Object.defineProperty(performance, 'now', {
|
|
631
|
+
configurable: true,
|
|
632
|
+
value: (()=>{
|
|
633
|
+
const values = [
|
|
634
|
+
200,
|
|
635
|
+
200,
|
|
636
|
+
205
|
|
637
|
+
];
|
|
638
|
+
return ()=>values.shift() ?? 205;
|
|
639
|
+
})()
|
|
640
|
+
});
|
|
641
|
+
try {
|
|
642
|
+
const fallbackResolution = await geocodePlace('兜底剩余不足一毫秒', {
|
|
643
|
+
timeoutMs: 5,
|
|
644
|
+
fetchImpl: async (input)=>{
|
|
645
|
+
if (String(input).includes('nominatim')) {
|
|
646
|
+
fallbackResolutionFetches += 1;
|
|
647
|
+
return jsonResponse([]);
|
|
648
|
+
}
|
|
649
|
+
primaryBudgetFetches += 1;
|
|
650
|
+
return jsonResponse({
|
|
651
|
+
results: []
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
assert.equal(fallbackResolution.ok, false);
|
|
656
|
+
assert.equal(primaryBudgetFetches, 1);
|
|
657
|
+
assert.equal(fallbackResolutionFetches, 0, 'sub-millisecond fallback budget must not fetch');
|
|
658
|
+
} finally{
|
|
659
|
+
Object.defineProperty(performance, 'now', {
|
|
660
|
+
configurable: true,
|
|
661
|
+
value: originalPerformanceNow
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const nonErrorRejection = await getForecast({
|
|
665
|
+
latitude: 25.6,
|
|
666
|
+
longitude: 100.2
|
|
667
|
+
}, {
|
|
668
|
+
fetchImpl: async ()=>{
|
|
669
|
+
throw Object.create(null);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
assert.equal(nonErrorRejection.ok, false);
|
|
673
|
+
assert.equal(nonErrorRejection.error, 'request failed');
|
|
674
|
+
const symbolRejection = await getForecast({
|
|
675
|
+
latitude: 25.6,
|
|
676
|
+
longitude: 100.2
|
|
677
|
+
}, {
|
|
678
|
+
fetchImpl: async ()=>{
|
|
679
|
+
throw Symbol('untrusted');
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
assert.equal(symbolRejection.ok, false);
|
|
683
|
+
assert.equal(symbolRejection.error, 'request failed');
|
|
684
|
+
const originalDateNow = Date.now;
|
|
685
|
+
Date.now = ()=>0;
|
|
686
|
+
try {
|
|
687
|
+
const rollback = await getForecast({
|
|
688
|
+
latitude: 25.6,
|
|
689
|
+
longitude: 100.2
|
|
690
|
+
}, {
|
|
691
|
+
timeoutMs: 20,
|
|
692
|
+
fetchImpl: mockFetch(()=>forecastBody(7))
|
|
693
|
+
});
|
|
694
|
+
assert.equal(rollback.ok, true, 'wall-clock rollback must not expand or break monotonic budget');
|
|
695
|
+
} finally{
|
|
696
|
+
Date.now = originalDateNow;
|
|
697
|
+
}
|
|
48
698
|
assert.equal(wmoLabel(0), '晴');
|
|
49
699
|
assert.equal(wmoLabel(95), '雷暴');
|
|
50
|
-
assert.match(wmoLabel(999), /天气码999
|
|
51
|
-
console.log('
|
|
52
|
-
console.log('\nWEATHER TESTS: 6/6 OK(
|
|
700
|
+
assert.match(wmoLabel(999), /天气码999/);
|
|
701
|
+
console.log('6. climate/schema guard/WMO OK');
|
|
702
|
+
console.log('\nWEATHER TESTS: deterministic 6/6 OK (live APIs are peripheral smoke only)');
|
|
53
703
|
|
|
54
704
|
|
|
55
|
-
//# sourceURL
|
|
705
|
+
//# sourceURL=ts/scripts/weather-tests.ts
|