mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(energy): V6 review findings (7 issues across 5 PRs) (#2861)
* fix(energy): V6 review findings — flow availability, ember proto bool, LNG stale/blocking, fixture accuracy
- Fix 1: Only show "Flow data unavailable" for chokepoints with PortWatch
flow coverage (hormuz, malacca, suez, bab_el_mandeb), not all corridors
- Fix 2: Correct proto comment on data_available field 9 to document
gas mode and both mode behavior
- Fix 3: Add ember_available bool field 50 to GetCountryEnergyProfile proto,
set server-side from spine.electricity or direct Ember key fallback
- Fix 4: Ember fallback reads energy:ember:v1:{code} when spine exists but
has no electricity block (or fossilShare is absent)
- Fix 6: Add IEA upstream fixture matching actual API response shape,
with golden test parsing through seeder parseRecord/buildIndex
- Fix 7: Add PortWatch ArcGIS fixture with all attributes.* fields used
by buildHistory, with golden test validating parsed output
* fix(energy): add emberAvailable to energy gate; use real buildHistory in portwatch test
* fix(energy): add Ember render block to renderEnergyProfile for Ember-only countries
* chore: regenerate OpenAPI specs after proto comment update
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -2207,6 +2207,29 @@ components:
|
||||
type: boolean
|
||||
ieaBelowObligation:
|
||||
type: boolean
|
||||
emberFossilShare:
|
||||
type: number
|
||||
format: double
|
||||
description: Phase 3 — Ember monthly electricity mix
|
||||
emberRenewShare:
|
||||
type: number
|
||||
format: double
|
||||
emberNuclearShare:
|
||||
type: number
|
||||
format: double
|
||||
emberCoalShare:
|
||||
type: number
|
||||
format: double
|
||||
emberGasShare:
|
||||
type: number
|
||||
format: double
|
||||
emberDemandTwh:
|
||||
type: number
|
||||
format: double
|
||||
emberDataMonth:
|
||||
type: string
|
||||
emberAvailable:
|
||||
type: boolean
|
||||
ComputeEnergyShockScenarioRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -47,7 +47,7 @@ message ComputeEnergyShockScenarioResponse {
|
||||
repeated ProductImpact products = 6;
|
||||
int32 effective_cover_days = 7;
|
||||
string assessment = 8;
|
||||
bool data_available = 9; // backward compat: equals jodi_oil_coverage
|
||||
bool data_available = 9; // oil mode: equals jodi_oil_coverage; gas mode: equals gas data available; both mode: true if either has data. Use per-source coverage fields 11-14 for precise checks.
|
||||
|
||||
// field 10 reserved for future use
|
||||
|
||||
|
||||
@@ -61,4 +61,14 @@ message GetCountryEnergyProfileResponse {
|
||||
int32 iea_days_of_cover = 39;
|
||||
bool iea_net_exporter = 40;
|
||||
bool iea_below_obligation = 41;
|
||||
|
||||
// Phase 3 — Ember monthly electricity mix
|
||||
double ember_fossil_share = 43;
|
||||
double ember_renew_share = 44;
|
||||
double ember_nuclear_share = 45;
|
||||
double ember_coal_share = 46;
|
||||
double ember_gas_share = 47;
|
||||
double ember_demand_twh = 48;
|
||||
string ember_data_month = 49;
|
||||
bool ember_available = 50; // true when Ember monthly data exists for this country
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ async function fetchAllPages(portname, sinceEpoch) {
|
||||
return all;
|
||||
}
|
||||
|
||||
function buildHistory(features) {
|
||||
export function buildHistory(features) {
|
||||
return features
|
||||
.filter(f => f.attributes?.date)
|
||||
.map(f => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server';
|
||||
|
||||
import { getCachedJson } from '../../../_shared/redis';
|
||||
import { ENERGY_SPINE_KEY_PREFIX } from '../../../_shared/cache-keys';
|
||||
import { ENERGY_SPINE_KEY_PREFIX, EMBER_ELECTRICITY_KEY_PREFIX } from '../../../_shared/cache-keys';
|
||||
|
||||
interface OwidMix {
|
||||
year?: number | null;
|
||||
@@ -109,6 +109,14 @@ interface EnergySpine {
|
||||
hydroShare?: number;
|
||||
importShare?: number;
|
||||
};
|
||||
electricity?: {
|
||||
fossilShare?: number | null;
|
||||
renewShare?: number | null;
|
||||
nuclearShare?: number | null;
|
||||
coalShare?: number | null;
|
||||
gasShare?: number | null;
|
||||
demandTwh?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const EMPTY: GetCountryEnergyProfileResponse = {
|
||||
@@ -154,6 +162,14 @@ const EMPTY: GetCountryEnergyProfileResponse = {
|
||||
ieaDaysOfCover: 0,
|
||||
ieaNetExporter: false,
|
||||
ieaBelowObligation: false,
|
||||
emberFossilShare: 0,
|
||||
emberRenewShare: 0,
|
||||
emberNuclearShare: 0,
|
||||
emberCoalShare: 0,
|
||||
emberGasShare: 0,
|
||||
emberDemandTwh: 0,
|
||||
emberDataMonth: '',
|
||||
emberAvailable: false,
|
||||
};
|
||||
|
||||
function n(v: number | null | undefined): number {
|
||||
@@ -164,10 +180,22 @@ function s(v: string | null | undefined): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
interface EmberData {
|
||||
fossilShare?: number | null;
|
||||
renewShare?: number | null;
|
||||
nuclearShare?: number | null;
|
||||
coalShare?: number | null;
|
||||
gasShare?: number | null;
|
||||
demandTwh?: number | null;
|
||||
dataMonth?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function buildResponseFromSpine(
|
||||
spine: EnergySpine,
|
||||
gasStorage: GasStorage | null,
|
||||
electricity: ElectricityEntry | null,
|
||||
emberData: EmberData | null,
|
||||
): GetCountryEnergyProfileResponse {
|
||||
const cov = spine.coverage ?? {};
|
||||
const src = spine.sources ?? {};
|
||||
@@ -177,6 +205,10 @@ function buildResponseFromSpine(
|
||||
|
||||
const electricityAvailable = electricity != null && electricity.priceMwhEur != null;
|
||||
|
||||
const resolvedEmber: EmberData | null = (spine.electricity != null && typeof spine.electricity.fossilShare === 'number')
|
||||
? spine.electricity
|
||||
: emberData;
|
||||
|
||||
return {
|
||||
mixAvailable: cov.hasMix === true,
|
||||
mixYear: n(src.mixYear),
|
||||
@@ -225,6 +257,15 @@ function buildResponseFromSpine(
|
||||
ieaDaysOfCover: n(oil.daysOfCover),
|
||||
ieaNetExporter: oil.netExporter === true,
|
||||
ieaBelowObligation: oil.belowObligation === true,
|
||||
|
||||
emberFossilShare: n(resolvedEmber?.fossilShare),
|
||||
emberRenewShare: n(resolvedEmber?.renewShare),
|
||||
emberNuclearShare: n(resolvedEmber?.nuclearShare),
|
||||
emberCoalShare: n(resolvedEmber?.coalShare),
|
||||
emberGasShare: n(resolvedEmber?.gasShare),
|
||||
emberDemandTwh: n(resolvedEmber?.demandTwh),
|
||||
emberDataMonth: s(resolvedEmber?.dataMonth),
|
||||
emberAvailable: resolvedEmber != null && typeof resolvedEmber.fossilShare === 'number',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,22 +290,31 @@ export async function getCountryEnergyProfile(
|
||||
const electricity = electricityResult.status === 'fulfilled' ? (electricityResult.value as ElectricityEntry | null) : null;
|
||||
|
||||
if (spine != null && typeof spine === 'object' && spine.coverage != null) {
|
||||
return buildResponseFromSpine(spine, gasStorage, electricity);
|
||||
let emberFallback: EmberData | null = null;
|
||||
if (!spine.electricity || typeof spine.electricity.fossilShare !== 'number') {
|
||||
const directEmber = await getCachedJson(`${EMBER_ELECTRICITY_KEY_PREFIX}${code}`, true).catch(() => null);
|
||||
if (directEmber && typeof directEmber === 'object') {
|
||||
emberFallback = directEmber as EmberData;
|
||||
}
|
||||
}
|
||||
return buildResponseFromSpine(spine, gasStorage, electricity, emberFallback);
|
||||
}
|
||||
|
||||
// Fallback: 4-key direct join (cold cache or countries not yet in spine)
|
||||
const [mixResult, jodiOilResult, jodiGasResult, ieaStocksResult] =
|
||||
const [mixResult, jodiOilResult, jodiGasResult, ieaStocksResult, emberResult] =
|
||||
await Promise.allSettled([
|
||||
getCachedJson(`energy:mix:v1:${code}`, true),
|
||||
getCachedJson(`energy:jodi-oil:v1:${code}`, true),
|
||||
getCachedJson(`energy:jodi-gas:v1:${code}`, true),
|
||||
getCachedJson(`energy:iea-oil-stocks:v1:${code}`, true),
|
||||
getCachedJson(`${EMBER_ELECTRICITY_KEY_PREFIX}${code}`, true),
|
||||
]);
|
||||
|
||||
const mix = mixResult.status === 'fulfilled' ? (mixResult.value as OwidMix | null) : null;
|
||||
const jodiOil = jodiOilResult.status === 'fulfilled' ? (jodiOilResult.value as JodiOil | null) : null;
|
||||
const jodiGas = jodiGasResult.status === 'fulfilled' ? (jodiGasResult.value as JodiGas | null) : null;
|
||||
const ieaStocks = ieaStocksResult.status === 'fulfilled' ? (ieaStocksResult.value as IeaStocks | null) : null;
|
||||
const emberData = emberResult.status === 'fulfilled' ? (emberResult.value as EmberData | null) : null;
|
||||
|
||||
const electricityAvailable = electricity != null && electricity.priceMwhEur != null;
|
||||
|
||||
@@ -316,5 +366,14 @@ export async function getCountryEnergyProfile(
|
||||
ieaDaysOfCover: n(ieaStocks?.daysOfCover),
|
||||
ieaNetExporter: ieaStocks?.netExporter === true,
|
||||
ieaBelowObligation: ieaStocks?.belowObligation === true,
|
||||
|
||||
emberFossilShare: n(emberData?.fossilShare),
|
||||
emberRenewShare: n(emberData?.renewShare),
|
||||
emberNuclearShare: n(emberData?.nuclearShare),
|
||||
emberCoalShare: n(emberData?.coalShare),
|
||||
emberGasShare: n(emberData?.gasShare),
|
||||
emberDemandTwh: n(emberData?.demandTwh),
|
||||
emberDataMonth: s(emberData?.dataMonth),
|
||||
emberAvailable: emberData != null && typeof emberData.fossilShare === 'number',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,6 +329,14 @@ export class CountryIntelManager implements AppModule {
|
||||
ieaDaysOfCover: profile.ieaDaysOfCover,
|
||||
ieaNetExporter: profile.ieaNetExporter,
|
||||
ieaBelowObligation: profile.ieaBelowObligation,
|
||||
emberFossilShare: profile.emberFossilShare,
|
||||
emberRenewShare: profile.emberRenewShare,
|
||||
emberNuclearShare: profile.emberNuclearShare,
|
||||
emberCoalShare: profile.emberCoalShare,
|
||||
emberGasShare: profile.emberGasShare,
|
||||
emberDemandTwh: profile.emberDemandTwh,
|
||||
emberDataMonth: profile.emberDataMonth,
|
||||
emberAvailable: profile.emberAvailable,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -346,6 +354,9 @@ export class CountryIntelManager implements AppModule {
|
||||
gasTotalDemandTj: 0, gasLngImportsTj: 0, gasPipeImportsTj: 0,
|
||||
gasLngShare: 0, ieaStocksAvailable: false, ieaStocksDataMonth: '',
|
||||
ieaDaysOfCover: 0, ieaNetExporter: false, ieaBelowObligation: false,
|
||||
emberFossilShare: 0, emberRenewShare: 0, emberNuclearShare: 0,
|
||||
emberCoalShare: 0, emberGasShare: 0, emberDemandTwh: 0,
|
||||
emberDataMonth: '', emberAvailable: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -122,6 +122,14 @@ export interface CountryEnergyProfileData {
|
||||
ieaDaysOfCover: number;
|
||||
ieaNetExporter: boolean;
|
||||
ieaBelowObligation: boolean;
|
||||
emberFossilShare: number;
|
||||
emberRenewShare: number;
|
||||
emberNuclearShare: number;
|
||||
emberCoalShare: number;
|
||||
emberGasShare: number;
|
||||
emberDemandTwh: number;
|
||||
emberDataMonth: string;
|
||||
emberAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface CountryPortActivityData {
|
||||
|
||||
@@ -497,7 +497,8 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
this.energyBody.replaceChildren();
|
||||
|
||||
const hasAny = data.mixAvailable || data.jodiOilAvailable || data.ieaStocksAvailable
|
||||
|| data.jodiGasAvailable || data.gasStorageAvailable || data.electricityAvailable;
|
||||
|| data.jodiGasAvailable || data.gasStorageAvailable || data.electricityAvailable
|
||||
|| data.emberAvailable;
|
||||
|
||||
if (!hasAny) {
|
||||
this.energyBody.append(this.makeEmpty('Energy data unavailable for this country.'));
|
||||
@@ -702,6 +703,67 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
this.energyBody.append(section);
|
||||
}
|
||||
|
||||
if (data.emberAvailable) {
|
||||
const section = this.el('div', '');
|
||||
section.style.cssText = 'margin-top:10px';
|
||||
const monthLabel = data.emberDataMonth || 'latest';
|
||||
section.append(this.el('div', 'cdp-subtitle', `Monthly Generation Mix (${monthLabel})`));
|
||||
|
||||
const segments: Array<{ label: string; color: string; value: number }> = [
|
||||
{ label: 'Fossil', color: '#8B4513', value: data.emberFossilShare },
|
||||
{ label: 'Renewable', color: '#22c55e', value: data.emberRenewShare },
|
||||
{ label: 'Nuclear', color: '#6A0DAD', value: data.emberNuclearShare },
|
||||
];
|
||||
const total = segments.reduce((acc, seg) => acc + seg.value, 0);
|
||||
const norm = total > 0 ? total : 1;
|
||||
|
||||
const bar = this.el('div', '');
|
||||
bar.style.cssText = 'display:flex;width:100%;height:10px;border-radius:4px;overflow:hidden;margin-bottom:6px';
|
||||
for (const seg of segments) {
|
||||
const pct = (seg.value / norm) * 100;
|
||||
if (pct <= 0.5) continue;
|
||||
const span = this.el('span', '');
|
||||
span.style.cssText = `width:${pct}%;background:${seg.color}`;
|
||||
bar.append(span);
|
||||
}
|
||||
section.append(bar);
|
||||
|
||||
const legend = this.el('div', '');
|
||||
for (const seg of segments) {
|
||||
const pct = (seg.value / norm) * 100;
|
||||
if (pct <= 0.5) continue;
|
||||
const row = this.el('div', '');
|
||||
row.style.cssText = 'font-size:11px;color:#aaa;display:flex;gap:4px;align-items:center';
|
||||
const dot = this.el('span', '');
|
||||
dot.textContent = '\u25CF';
|
||||
dot.style.color = seg.color;
|
||||
const label = this.el('span', '', `${seg.label} ${Math.round(pct)}%`);
|
||||
row.append(dot, label);
|
||||
legend.append(row);
|
||||
}
|
||||
section.append(legend);
|
||||
|
||||
if (data.emberCoalShare > 0 || data.emberGasShare > 0) {
|
||||
const breakdown = this.el('div', '');
|
||||
breakdown.style.cssText = 'font-size:11px;color:#aaa;margin-top:4px';
|
||||
const parts: string[] = [];
|
||||
if (data.emberCoalShare > 0) parts.push(`Coal ${Math.round(data.emberCoalShare)}%`);
|
||||
if (data.emberGasShare > 0) parts.push(`Gas ${Math.round(data.emberGasShare)}%`);
|
||||
breakdown.textContent = `Fossil breakdown: ${parts.join(', ')}`;
|
||||
section.append(breakdown);
|
||||
}
|
||||
|
||||
if (data.emberDemandTwh > 0) {
|
||||
const demand = this.el('div', '');
|
||||
demand.style.cssText = 'font-size:11px;color:#aaa;margin-top:2px';
|
||||
demand.textContent = `Total demand: ${data.emberDemandTwh.toFixed(1)} TWh`;
|
||||
section.append(demand);
|
||||
}
|
||||
|
||||
section.append(this.el('div', 'cdp-economic-source', 'Source: Ember Climate (monthly)'));
|
||||
this.energyBody!.append(section);
|
||||
}
|
||||
|
||||
if (data.jodiOilAvailable || data.jodiGasAvailable) {
|
||||
this.energyBody.append(this.renderShockScenarioWidget());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { isDesktopRuntime } from '@/services/runtime';
|
||||
|
||||
type TabId = 'chokepoints' | 'shipping' | 'indicators' | 'minerals' | 'stress';
|
||||
|
||||
const FLOW_SUPPORTED_IDS = new Set(['hormuz_strait', 'malacca_strait', 'suez', 'bab_el_mandeb']);
|
||||
|
||||
export class SupplyChainPanel extends Panel {
|
||||
private shippingData: GetShippingRatesResponse | null = null;
|
||||
private chokepointData: GetChokepointStatusResponse | null = null;
|
||||
@@ -214,7 +216,9 @@ export class SupplyChainPanel extends Panel {
|
||||
return `<div class="sc-metric-row" style="color:${flowColor}">
|
||||
<span>~${fe.currentMbd} mb/d <span style="opacity:0.7">(${pct}% of ${fe.baselineMbd} baseline)</span>${hazardBadge}</span>
|
||||
</div>`;
|
||||
})() : ''}
|
||||
})() : FLOW_SUPPORTED_IDS.has(cp.id) ? `<div class="sc-metric-row" style="color:var(--text-dim,#888);font-size:11px;opacity:0.7">
|
||||
<span>${t('components.supplyChain.flowUnavailable')}</span>
|
||||
</div>` : ''}
|
||||
${cp.description ? `<div class="trade-description">${escapeHtml(cp.description)}</div>` : ''}
|
||||
<div class="trade-affected">${cp.affectedRoutes.slice(0, 3).map(r => escapeHtml(r)).join(', ')}</div>
|
||||
${actionRow}
|
||||
|
||||
@@ -530,6 +530,14 @@ export interface GetCountryEnergyProfileResponse {
|
||||
ieaDaysOfCover: number;
|
||||
ieaNetExporter: boolean;
|
||||
ieaBelowObligation: boolean;
|
||||
emberFossilShare: number;
|
||||
emberRenewShare: number;
|
||||
emberNuclearShare: number;
|
||||
emberCoalShare: number;
|
||||
emberGasShare: number;
|
||||
emberDemandTwh: number;
|
||||
emberDataMonth: string;
|
||||
emberAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ComputeEnergyShockScenarioRequest {
|
||||
|
||||
@@ -530,6 +530,14 @@ export interface GetCountryEnergyProfileResponse {
|
||||
ieaDaysOfCover: number;
|
||||
ieaNetExporter: boolean;
|
||||
ieaBelowObligation: boolean;
|
||||
emberFossilShare: number;
|
||||
emberRenewShare: number;
|
||||
emberNuclearShare: number;
|
||||
emberCoalShare: number;
|
||||
emberGasShare: number;
|
||||
emberDemandTwh: number;
|
||||
emberDataMonth: string;
|
||||
emberAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ComputeEnergyShockScenarioRequest {
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"disruption": "Disruption",
|
||||
"vessels": "vessels",
|
||||
"incidents7d": "incidents (7d)",
|
||||
"flowUnavailable": "Flow data unavailable",
|
||||
"containerRates": "Container Rates",
|
||||
"bulkShipping": "Bulk Shipping",
|
||||
"economicIndicators": "Economic Indicators",
|
||||
|
||||
86
tests/fixtures/iea-stocks-sample.json
vendored
Normal file
86
tests/fixtures/iea-stocks-sample.json
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"latest": { "year": 2025, "month": 11 },
|
||||
"monthly": [
|
||||
{
|
||||
"countryName": "Total",
|
||||
"yearMonth": 202511,
|
||||
"total": "145",
|
||||
"industry": "145",
|
||||
"publicData": "0",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "Germany",
|
||||
"yearMonth": 202511,
|
||||
"total": "130",
|
||||
"industry": "110",
|
||||
"publicData": "20",
|
||||
"abroadIndustry": "5",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "France",
|
||||
"yearMonth": 202511,
|
||||
"total": "100",
|
||||
"industry": "85",
|
||||
"publicData": "15",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "Norway",
|
||||
"yearMonth": 202511,
|
||||
"total": "Net Exporter",
|
||||
"industry": "0",
|
||||
"publicData": "0",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "Greece",
|
||||
"yearMonth": 202511,
|
||||
"total": "75",
|
||||
"industry": "75",
|
||||
"publicData": "0",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "Estonia",
|
||||
"yearMonth": 202511,
|
||||
"total": "11111",
|
||||
"industry": "11111",
|
||||
"publicData": "0",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "Japan",
|
||||
"yearMonth": 202511,
|
||||
"total": "171",
|
||||
"industry": "171",
|
||||
"publicData": "0",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "Canada",
|
||||
"yearMonth": 202511,
|
||||
"total": "Net Exporter",
|
||||
"industry": "0",
|
||||
"publicData": "0",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
},
|
||||
{
|
||||
"countryName": "United States",
|
||||
"yearMonth": 202511,
|
||||
"total": "Net Exporter",
|
||||
"industry": "0",
|
||||
"publicData": "0",
|
||||
"abroadIndustry": "0",
|
||||
"abroadPublic": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
53
tests/fixtures/portwatch-arcgis-sample.json
vendored
Normal file
53
tests/fixtures/portwatch-arcgis-sample.json
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"attributes": {
|
||||
"date": 1735689600000,
|
||||
"n_container": 12,
|
||||
"n_dry_bulk": 8,
|
||||
"n_general_cargo": 5,
|
||||
"n_roro": 2,
|
||||
"n_tanker": 15,
|
||||
"n_total": 42,
|
||||
"capacity_container": 450000,
|
||||
"capacity_dry_bulk": 320000,
|
||||
"capacity_general_cargo": 100000,
|
||||
"capacity_roro": 60000,
|
||||
"capacity_tanker": 1200000
|
||||
}
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"date": 1735776000000,
|
||||
"n_container": 14,
|
||||
"n_dry_bulk": 9,
|
||||
"n_general_cargo": 4,
|
||||
"n_roro": 3,
|
||||
"n_tanker": 18,
|
||||
"n_total": 48,
|
||||
"capacity_container": 520000,
|
||||
"capacity_dry_bulk": 340000,
|
||||
"capacity_general_cargo": 85000,
|
||||
"capacity_roro": 72000,
|
||||
"capacity_tanker": 1450000
|
||||
}
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"date": 1735862400000,
|
||||
"n_container": 10,
|
||||
"n_dry_bulk": 7,
|
||||
"n_general_cargo": 6,
|
||||
"n_roro": 1,
|
||||
"n_tanker": 13,
|
||||
"n_total": 37,
|
||||
"capacity_container": 380000,
|
||||
"capacity_dry_bulk": 290000,
|
||||
"capacity_general_cargo": 120000,
|
||||
"capacity_roro": 30000,
|
||||
"capacity_tanker": 1050000
|
||||
}
|
||||
}
|
||||
],
|
||||
"exceededTransferLimit": false
|
||||
}
|
||||
102
tests/iea-stocks-fixture.test.mjs
Normal file
102
tests/iea-stocks-fixture.test.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseRecord, buildIndex, buildOilStocksAnalysis } from '../scripts/seed-iea-oil-stocks.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const fixture = JSON.parse(readFileSync(resolve(__dirname, 'fixtures/iea-stocks-sample.json'), 'utf-8'));
|
||||
const FIXED_TS = '2026-04-05T08:00:00.000Z';
|
||||
|
||||
describe('IEA fixture matches upstream shape', () => {
|
||||
it('latest response has year and month', () => {
|
||||
assert.equal(typeof fixture.latest.year, 'number');
|
||||
assert.equal(typeof fixture.latest.month, 'number');
|
||||
});
|
||||
|
||||
it('monthly is an array of records', () => {
|
||||
assert.ok(Array.isArray(fixture.monthly));
|
||||
assert.ok(fixture.monthly.length > 0);
|
||||
});
|
||||
|
||||
it('each record has the fields the seeder reads', () => {
|
||||
const requiredFields = ['countryName', 'yearMonth', 'total', 'industry', 'publicData', 'abroadIndustry', 'abroadPublic'];
|
||||
for (const record of fixture.monthly) {
|
||||
for (const field of requiredFields) {
|
||||
assert.ok(field in record, `record for ${record.countryName} missing field: ${field}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('IEA fixture parsed through seeder parseRecord', () => {
|
||||
const nonTotalRecords = fixture.monthly.filter(r => !r.countryName?.startsWith('Total'));
|
||||
const parsed = nonTotalRecords.map(r => parseRecord(r, FIXED_TS)).filter(Boolean);
|
||||
|
||||
it('parses at least 5 valid members from the fixture', () => {
|
||||
assert.ok(parsed.length >= 5, `expected >= 5, got ${parsed.length}`);
|
||||
});
|
||||
|
||||
it('Total records are skipped by having no COUNTRY_MAP entry', () => {
|
||||
const totalRecord = fixture.monthly.find(r => r.countryName === 'Total');
|
||||
assert.ok(totalRecord, 'fixture must include a Total row');
|
||||
assert.equal(parseRecord(totalRecord, FIXED_TS), null);
|
||||
});
|
||||
|
||||
it('Germany parsed with correct daysOfCover and breakdown', () => {
|
||||
const de = parsed.find(m => m.iso2 === 'DE');
|
||||
assert.ok(de);
|
||||
assert.equal(de.daysOfCover, 130);
|
||||
assert.equal(de.netExporter, false);
|
||||
assert.equal(de.industryDays, 110);
|
||||
assert.equal(de.publicDays, 20);
|
||||
assert.equal(de.abroadDays, 5);
|
||||
});
|
||||
|
||||
it('Norway parsed as net exporter', () => {
|
||||
const no = parsed.find(m => m.iso2 === 'NO');
|
||||
assert.ok(no);
|
||||
assert.equal(no.netExporter, true);
|
||||
assert.equal(no.daysOfCover, null);
|
||||
});
|
||||
|
||||
it('Greece parsed below obligation', () => {
|
||||
const gr = parsed.find(m => m.iso2 === 'GR');
|
||||
assert.ok(gr);
|
||||
assert.equal(gr.belowObligation, true);
|
||||
assert.equal(gr.daysOfCover, 75);
|
||||
});
|
||||
|
||||
it('Estonia parsed as anomaly (total > 500)', () => {
|
||||
const ee = parsed.find(m => m.iso2 === 'EE');
|
||||
assert.ok(ee);
|
||||
assert.equal(ee.anomaly, true);
|
||||
assert.equal(ee.daysOfCover, null);
|
||||
});
|
||||
|
||||
it('buildIndex produces valid shape from fixture', () => {
|
||||
const index = buildIndex(parsed, '2025-11', FIXED_TS);
|
||||
assert.equal(index.dataMonth, '2025-11');
|
||||
assert.ok(Array.isArray(index.members));
|
||||
assert.ok(index.members.length >= 5);
|
||||
for (const m of index.members) {
|
||||
assert.ok('iso2' in m);
|
||||
assert.ok('daysOfCover' in m);
|
||||
assert.ok('netExporter' in m);
|
||||
assert.ok('belowObligation' in m);
|
||||
}
|
||||
});
|
||||
|
||||
it('buildOilStocksAnalysis produces valid shape from fixture', () => {
|
||||
const analysis = buildOilStocksAnalysis(parsed, '2025-11', FIXED_TS);
|
||||
assert.ok(Array.isArray(analysis.ieaMembers));
|
||||
assert.ok(analysis.ieaMembers.length > 0);
|
||||
assert.ok(analysis.regionalSummary);
|
||||
assert.ok(analysis.regionalSummary.europe);
|
||||
assert.ok(analysis.regionalSummary.asiaPacific);
|
||||
assert.ok(analysis.regionalSummary.northAmerica);
|
||||
assert.ok(Array.isArray(analysis.belowObligation));
|
||||
assert.ok(analysis.belowObligation.includes('GR'));
|
||||
});
|
||||
});
|
||||
102
tests/portwatch-fixture.test.mjs
Normal file
102
tests/portwatch-fixture.test.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildHistory } from '../scripts/seed-portwatch.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const fixture = JSON.parse(readFileSync(resolve(__dirname, 'fixtures/portwatch-arcgis-sample.json'), 'utf-8'));
|
||||
|
||||
const ARCGIS_FIELDS_USED_BY_BUILD_HISTORY = [
|
||||
'date',
|
||||
'n_container',
|
||||
'n_dry_bulk',
|
||||
'n_general_cargo',
|
||||
'n_roro',
|
||||
'n_tanker',
|
||||
'n_total',
|
||||
'capacity_container',
|
||||
'capacity_dry_bulk',
|
||||
'capacity_general_cargo',
|
||||
'capacity_roro',
|
||||
'capacity_tanker',
|
||||
];
|
||||
|
||||
describe('PortWatch ArcGIS fixture matches upstream shape', () => {
|
||||
it('has features array', () => {
|
||||
assert.ok(Array.isArray(fixture.features));
|
||||
assert.ok(fixture.features.length > 0);
|
||||
});
|
||||
|
||||
it('has exceededTransferLimit field', () => {
|
||||
assert.ok('exceededTransferLimit' in fixture);
|
||||
});
|
||||
|
||||
it('each feature has all attributes used by buildHistory', () => {
|
||||
for (const feature of fixture.features) {
|
||||
assert.ok(feature.attributes, 'feature must have attributes');
|
||||
for (const field of ARCGIS_FIELDS_USED_BY_BUILD_HISTORY) {
|
||||
assert.ok(field in feature.attributes, `missing attribute: ${field}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PortWatch fixture parsed through buildHistory', () => {
|
||||
const history = buildHistory(fixture.features);
|
||||
|
||||
it('produces correct number of entries', () => {
|
||||
assert.equal(history.length, fixture.features.length);
|
||||
});
|
||||
|
||||
it('entries are sorted by date ascending', () => {
|
||||
for (let i = 1; i < history.length; i++) {
|
||||
assert.ok(history[i].date >= history[i - 1].date, 'dates must be ascending');
|
||||
}
|
||||
});
|
||||
|
||||
it('each entry has all required output fields', () => {
|
||||
const requiredFields = [
|
||||
'date', 'container', 'dryBulk', 'generalCargo', 'roro', 'tanker',
|
||||
'cargo', 'other', 'total',
|
||||
'capContainer', 'capDryBulk', 'capGeneralCargo', 'capRoro', 'capTanker',
|
||||
];
|
||||
for (const entry of history) {
|
||||
for (const field of requiredFields) {
|
||||
assert.ok(field in entry, `missing field: ${field}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('cargo is sum of container + dryBulk + generalCargo + roro', () => {
|
||||
for (const entry of history) {
|
||||
assert.equal(entry.cargo, entry.container + entry.dryBulk + entry.generalCargo + entry.roro);
|
||||
}
|
||||
});
|
||||
|
||||
it('first entry has expected values from fixture', () => {
|
||||
const first = history[0];
|
||||
assert.equal(first.container, 12);
|
||||
assert.equal(first.dryBulk, 8);
|
||||
assert.equal(first.generalCargo, 5);
|
||||
assert.equal(first.roro, 2);
|
||||
assert.equal(first.tanker, 15);
|
||||
assert.equal(first.total, 42);
|
||||
assert.equal(first.capContainer, 450000);
|
||||
assert.equal(first.capDryBulk, 320000);
|
||||
assert.equal(first.capGeneralCargo, 100000);
|
||||
assert.equal(first.capRoro, 60000);
|
||||
assert.equal(first.capTanker, 1200000);
|
||||
});
|
||||
|
||||
it('capacity fields are numeric', () => {
|
||||
for (const entry of history) {
|
||||
assert.equal(typeof entry.capContainer, 'number');
|
||||
assert.equal(typeof entry.capDryBulk, 'number');
|
||||
assert.equal(typeof entry.capGeneralCargo, 'number');
|
||||
assert.equal(typeof entry.capRoro, 'number');
|
||||
assert.equal(typeof entry.capTanker, 'number');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user