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:
Elie Habib
2026-04-09 12:40:13 +04:00
committed by GitHub
parent 29925204fa
commit cffdcf8052
17 changed files with 545 additions and 8 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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:

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 => {

View File

@@ -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',
};
}

View File

@@ -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,
});
});

View File

@@ -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 {

View File

@@ -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());
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
View 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"
}
]
}

View 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
}

View 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'));
});
});

View 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');
}
});
});