From 8f8819276f834cb6bb9299a88b824d64ce0aa956 Mon Sep 17 00:00:00 2001 From: Cory Sanin Date: Fri, 29 Aug 2025 01:37:02 -0500 Subject: [PATCH] switch out weather library --- package-lock.json | 57 +------ package.json | 2 +- src/index.ts | 6 +- src/sequencer.ts | 86 ++++++++--- src/weather.ts | 101 ------------- test/sequencer.test.ts | 43 ++++++ test/weather.test.ts | 332 ----------------------------------------- 7 files changed, 120 insertions(+), 507 deletions(-) delete mode 100644 src/weather.ts create mode 100644 test/sequencer.test.ts delete mode 100644 test/weather.test.ts diff --git a/package-lock.json b/package-lock.json index f277c4f..b387e24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "json5": "2.2.3", - "openweathermap-ts": "1.2.10" + "openweather-api-node": "3.1.5" }, "devDependencies": { "@types/node": "24.3.0", @@ -1622,34 +1622,11 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/openweathermap-ts": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/openweathermap-ts/-/openweathermap-ts-1.2.10.tgz", - "integrity": "sha512-Zckv2aXN8ENSeAeroces2jJciLWb6aLNXEmvG6pmF+BcIMw2kwRo6++/AKUNoU5suOp47UWA6lllDV0TNm//OA==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.6.0" - } + "node_modules/openweather-api-node": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/openweather-api-node/-/openweather-api-node-3.1.5.tgz", + "integrity": "sha512-FGLE0bWOTvp4XHaswmzMfisYMMEtwEwOEJR0vaS07L31OUcutV/UUO5/vRuktkRPoqfk3KZOoqddsRTGTxT7Aw==", + "license": "MIT" }, "node_modules/package-json-from-dist": { "version": "1.0.1", @@ -2077,12 +2054,6 @@ "node": ">=14.0.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -2275,22 +2246,6 @@ } } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3d4c630..894e4ff 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "json5": "2.2.3", - "openweathermap-ts": "1.2.10" + "openweather-api-node": "3.1.5" }, "devDependencies": { "typescript": "5.9.2", diff --git a/src/index.ts b/src/index.ts index 6079c33..6f4707e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import json5 from 'json5'; import Sequencer from './sequencer.js'; import type {Programs, Segments, Sequences} from './sequencer.js'; import type { Voices } from './voice.js'; -import type { WeatherConfig } from './weather.js'; +import type { Options } from 'openweather-api-node'; interface Config { @@ -12,13 +12,13 @@ interface Config { segments: Segments, sequences: Sequences, voices: Voices, - weather: WeatherConfig + weather: Options } console.log('morning-report\nCory Sanin 2025\n'); const config: Config = json5.parse(await fsp.readFile(process.env['CONFIG'] || path.join('config', 'config.json5'), { encoding: 'utf-8' })); -const sequence = Sequencer(config); +const sequence = await Sequencer(config); console.log(sequence.join('\n')); diff --git a/src/sequencer.ts b/src/sequencer.ts index f87ff5d..16ad1e9 100644 --- a/src/sequencer.ts +++ b/src/sequencer.ts @@ -1,11 +1,14 @@ +import { OpenWeatherAPI, type CurrentWeather } from 'openweather-api-node'; +import { voiceLines } from './voice.js'; import type { Config } from './index.js'; +import type { Voice } from './voice.js'; type SegmentName = string; type SequenceName = string; type Programs = SegmentName[][]; type Segments = { [segment: SegmentName]: SequenceName[] }; type Sequence = { - condition?: string; + conditions?: string[]; tracks: string[]; } type Sequences = { [sequence: SequenceName]: Sequence }; @@ -16,36 +19,81 @@ function selectOne(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } -function conditionIsMet(condition: string | undefined = undefined): boolean { +function resolveSide(side: string, currentWeather: CurrentWeather) { + if (!side.startsWith('weather')) { + return side.includes('.') ? parseFloat(side) : parseInt(side); + } + + const tokens = side.split('.'); + let w = currentWeather; + tokens.forEach(t => w = w[t]); + return typeof w === 'object' ? JSON.stringify(w) : w as (string | number); +} + +function conditionIsMet(condition: string | undefined, currentWeather: CurrentWeather): boolean { if (typeof condition !== 'string') { return true; } - // TODO: parse condition, return bool - return false; -} - -function processSequence(sequence: Sequence): string[] { - const tracks = sequence.tracks; - // TODO: process voice macros - return tracks; -} - -function processSegment(segment: SegmentName): string[] { - if (!(segment in config.segments)) { - return processSequence(config.sequences[segment]); + const [lhs, relational, rhs] = condition.split(' '); + const lhsResolved = resolveSide(lhs, currentWeather); + const rhsResolved = resolveSide(rhs, currentWeather); + switch (relational) { + case '=': + case '==': + return lhsResolved == rhsResolved; + case '<': + return lhsResolved < rhsResolved; + case '<=': + return lhsResolved <= rhsResolved; + case '>': + return lhsResolved > rhsResolved; + case '>=': + return lhsResolved >= rhsResolved; + default: + throw new Error(`Unsupported relational operator: ${relational}`); } - const potentialSequences: SequenceName[] = config.segments[segment].filter(s => conditionIsMet(config.sequences[s].condition)); +} + +function resolveMacro(str: string, currentWeather: CurrentWeather): string[] { + if (str.startsWith('%')) { + const [profile, subject] = str.substring(1).split(' ', 2); + const voiceProfile: Voice = config.voices[profile]; + let resolvedSubject: any = currentWeather; + subject.split('.').forEach(t => resolvedSubject = resolvedSubject[t]); + return voiceLines(voiceProfile, resolvedSubject); + } + else if (str.startsWith('$')) { + return null; + } + return [str]; +} + +function processSequence(sequence: Sequence, currentWeather: CurrentWeather): string[] { + const tracks = sequence.tracks; + return tracks.map(t => resolveMacro(t, currentWeather)).flat().filter(t => t !== null); +} + +function processSegment(segment: SegmentName, currentWeather: CurrentWeather): string[] { + if (!(segment in config.segments)) { + return processSequence(config.sequences[segment], currentWeather); + } + const potentialSequences: SequenceName[] = config.segments[segment].filter(s => (config.sequences[s].conditions || []).every(c => conditionIsMet(c, currentWeather))); if (potentialSequences.length === 0) { return []; } - return processSequence(config.sequences[selectOne(potentialSequences)]); + return processSequence(config.sequences[selectOne(potentialSequences)], currentWeather); } -function Sequencer(conf: Config): string[] { +async function Sequencer(conf: Config): Promise { config = conf; + const weather = new OpenWeatherAPI(conf.weather); + const currentWeather = await weather.getCurrent(); const sequence: string[] = []; const program: SegmentName[] = selectOne(conf.programs); - program.forEach(segment => sequence.push(...processSegment(segment))); + for (let i = 0; i < program.length; i++) { + const segment = program[i]; + sequence.push(...(processSegment(segment, currentWeather))); + } return sequence; } diff --git a/src/weather.ts b/src/weather.ts deleted file mode 100644 index 03a9670..0000000 --- a/src/weather.ts +++ /dev/null @@ -1,101 +0,0 @@ -import OpenWeatherMap from 'openweathermap-ts'; -import type { ThreeHourResponse, CurrentResponse, CountryCode } from 'openweathermap-ts/dist/types/index.js'; - -interface CityName { - cityName: string; - state: string; - countryCode: CountryCode; -} - -interface WeatherConfig { - key: string; - lang?: string; - coordinates?: number[] | string; - zip?: number; - country?: CountryCode; - cityid?: number; - city?: CityName; -} - -type LocationType = 'coordinates' | 'zip' | 'cityid' | 'city' | null; - -function parseCoords(coords: number[] | string): number[] { - if (typeof coords == 'string') { - return coords.replace(/\s/g, '').split(',').map(Number.parseFloat); - } - return coords; -} - - -class Weather { - private openWeather: OpenWeatherMap.default; - private locationType: LocationType; - private current: CurrentResponse; - private threeDay: ThreeHourResponse; - - constructor(options: WeatherConfig) { - this.locationType = this.current = this.threeDay = null; - this.openWeather = new OpenWeatherMap.default({ - apiKey: options.key - }); - if ('city' in options && 'cityName' in options.city && 'state' in options.city && 'countryCode' in options.city) { - this.openWeather.setCityName(options.city); - this.locationType = 'city'; - } - if ('cityid' in options) { - this.openWeather.setCityId(options.cityid); - this.locationType = 'cityid'; - } - if ('zip' in options && 'country' in options) { - this.openWeather.setZipCode(options.zip, options.country) - this.locationType = 'zip'; - } - if ('coordinates' in options) { - const coords = parseCoords(options.coordinates); - if (coords.length >= 2) { - this.openWeather.setGeoCoordinates(coords[0], coords[1]); - this.locationType = 'coordinates'; - } - } - } - - async getCurrentWeather(): Promise { - if (this.current) { - return this.current; - } - switch (this.locationType) { - case 'city': - return this.current = await this.openWeather.getCurrentWeatherByCityName(); - case 'cityid': - return this.current = await this.openWeather.getCurrentWeatherByCityId(); - case 'zip': - return this.current = await this.openWeather.getCurrentWeatherByZipcode(); - case 'coordinates': - return this.current = await this.openWeather.getCurrentWeatherByGeoCoordinates(); - default: - throw new Error(`Can't fetch weather for location type '${this.locationType}'`); - } - } - - async getThreeHourForecast(): Promise { - if (this.threeDay) { - return this.threeDay; - } - switch (this.locationType) { - case 'city': - return this.threeDay = await this.openWeather.getThreeHourForecastByCityName(); - case 'cityid': - return this.threeDay = await this.openWeather.getThreeHourForecastByCityId(); - case 'zip': - return this.threeDay = await this.openWeather.getThreeHourForecastByZipcode(); - case 'coordinates': - return this.threeDay = await this.openWeather.getThreeHourForecastByGeoCoordinates(); - default: - throw new Error(`Can't fetch weather for location type '${this.locationType}'`); - } - } -} - -export default Weather; -export { Weather }; -export type { WeatherConfig, CityName, CurrentResponse, ThreeHourResponse }; diff --git a/test/sequencer.test.ts b/test/sequencer.test.ts new file mode 100644 index 0000000..a1d25e4 --- /dev/null +++ b/test/sequencer.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { type CurrentWeather, type Options } from 'openweather-api-node'; +import { Sequencer } from '../src/sequencer.js'; +import type { Config } from '../src/index.js'; + +const dummyWeather: Options = { key: 'dummy' }; + +vi.mock('openweather-api-node', () => { + return { + OpenWeatherAPI: vi.fn().mockImplementation((_) => { + return { + getCurrent: vi.fn(() => {}) + } + }) + } +}); + +describe('sequencer', () => { + it('can generate a list from a static config', async () => { + expect(await Sequencer({ + programs: [['sequence 1', 'segment 1', 'segment 2']], + segments: { + 'segment 1': ['sequence 1'], + 'segment 2': ['sequence 2'] + }, + sequences: { + 'sequence 1': { + 'tracks': [ + 'seq1.flac' + ] + }, + 'sequence 2': { + 'tracks': [ + 'seq2.flac' + ] + } + }, + voices: {}, + weather: dummyWeather + })).to.include.ordered.members(['seq1.flac', 'seq1.flac', 'seq2.flac']); + }); +}); + diff --git a/test/weather.test.ts b/test/weather.test.ts deleted file mode 100644 index fe6a666..0000000 --- a/test/weather.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import OpenWeatherMap from 'openweathermap-ts'; -import { describe, expect, it, vi, beforeEach, type Mocked } from 'vitest'; -import { Weather } from '../src/weather.js'; -import type { CurrentResponse, ThreeHourResponse } from '../src/weather.js'; - -// #region mock API responses -const current: CurrentResponse = { - "coord": { - "lon": 7.367, - "lat": 45.133 - }, - "weather": [ - { - "id": 501, - "main": "Rain", - "description": "moderate rain", - "icon": "10d" - } - ], - "base": "stations", - "main": { - "temp": 284.2, - "feels_like": 282.93, - "temp_min": 283.06, - "temp_max": 286.82, - "pressure": 1021, - "humidity": 60 - }, - "visibility": 10000, - "wind": { - "speed": 4.09, - "deg": 121 - }, - "clouds": { - "all": 83 - }, - "dt": 1726660758, - "sys": { - "type": 1, - "id": 6736, - "country": "IT", - "sunrise": 1726636384, - "sunset": 1726680975 - }, - "timezone": 7200, - "id": 3165523, - "name": "Province of Turin", - "cod": 200 -}; - -const threeHour: ThreeHourResponse = { - "cod": "200", - "message": 0, - "cnt": 96, - "list": [ - { - "dt": 1661875200, - "main": { - "temp": 296.34, - "temp_min": 296.34, - "temp_max": 298.24, - "pressure": 1015, - "sea_level": 1015, - "grnd_level": 933, - "humidity": 50, - "temp_kf": -1.9 - }, - "weather": [ - { - "id": 500, - "main": "Rain", - "description": "light rain", - "icon": "10d" - } - ], - "clouds": { - "all": 97 - }, - "wind": { - "speed": 1.06, - "deg": 66 - }, - "rain": { - "3h": 1 - }, - "sys": { - "pod": "d" - }, - "dt_txt": "2022-08-30 16:00:00" - }, - { - "dt": 1661878800, - "main": { - "temp": 296.31, - "temp_min": 296.2, - "temp_max": 296.31, - "pressure": 1015, - "sea_level": 1015, - "grnd_level": 932, - "humidity": 53, - "temp_kf": 0.11 - }, - "weather": [ - { - "id": 500, - "main": "Rain", - "description": "light rain", - "icon": "10d" - } - ], - "clouds": { - "all": 95 - }, - "wind": { - "speed": 1.58, - "deg": 103 - }, - "rain": { - "3h": 0.24 - }, - "sys": { - "pod": "d" - }, - "dt_txt": "2022-08-30 17:00:00" - }, - { - "dt": 1661882400, - "main": { - "temp": 294.94, - "temp_min": 292.84, - "temp_max": 294.94, - "pressure": 1015, - "sea_level": 1015, - "grnd_level": 931, - "humidity": 60, - "temp_kf": 2.1 - }, - "weather": [ - { - "id": 500, - "main": "Rain", - "description": "light rain", - "icon": "10n" - } - ], - "clouds": { - "all": 93 - }, - "wind": { - "speed": 1.97, - "deg": 157 - }, - "rain": { - "3h": 0.2 - }, - "sys": { - "pod": "n" - }, - "dt_txt": "2022-08-30 18:00:00" - }, - { - "dt": 1662217200, - "main": { - "temp": 294.14, - "temp_min": 294.14, - "temp_max": 294.14, - "pressure": 1014, - "sea_level": 1014, - "grnd_level": 931, - "humidity": 65, - "temp_kf": 0 - }, - "weather": [ - { - "id": 804, - "main": "Clouds", - "description": "overcast clouds", - "icon": "04d" - } - ], - "clouds": { - "all": 100 - }, - "wind": { - "speed": 0.91, - "deg": 104 - }, - "sys": { - "pod": "d" - }, - "dt_txt": "2022-09-03 15:00:00" - } - ], - "city": { - "id": 3163858, - "name": "Zocca", - "coord": { - "lat": 44.34, - "lon": 10.99 - }, - "country": "IT" - } -} -// #endregion - -vi.mock('openweathermap-ts', () => { - return { - default: { - default: vi.fn().mockImplementation((_) => { - return { - setCityName: vi.fn(() => undefined), - setCityId: vi.fn(() => undefined), - setZipCode: vi.fn(() => undefined), - setGeoCoordinates: vi.fn(() => undefined), - getCurrentWeatherByCityName: vi.fn(async () => current), - getCurrentWeatherByCityId: vi.fn(async () => current), - getCurrentWeatherByZipcode: vi.fn(async () => current), - getCurrentWeatherByGeoCoordinates: vi.fn(async () => current), - getThreeHourForecastByCityName: vi.fn(async () => threeHour), - getThreeHourForecastByCityId: vi.fn(async () => threeHour), - getThreeHourForecastByZipcode: vi.fn(async () => threeHour), - getThreeHourForecastByGeoCoordinates: vi.fn(async () => threeHour), - } - }) - } - } -}); - -let weather: Weather = null; - -describe.for( - [ - { - weatherFactory: () => new Weather({ - key: 'api-key', - city: { - cityName: 'Madison', - state: 'Wisconsin', - countryCode: 'US' - } - }), - by: 'CityName' - }, - { - weatherFactory: () => new Weather({ - key: 'api-key', - cityid: 1 - }), - by: 'CityId' - }, - { - weatherFactory: () => new Weather({ - key: 'api-key', - zip: 53702, - country: 'US' - }), - by: 'Zipcode' - }, - { - weatherFactory: () => new Weather({ - key: 'api-key', - coordinates: [0, 0] - }), - by: 'GeoCoordinates' - }, - { - weatherFactory: () => new Weather({ - key: 'api-key', - coordinates: '1000 , 1000' - }), - by: 'GeoCoordinates' - } - ])('weather API using city name', ({ weatherFactory, by }) => { - beforeEach(() => { - vi.clearAllMocks(); - weather = weatherFactory(); - }); - - it('gets current weather', async () => { - expect(await weather.getCurrentWeather()).to.deep.equal(current); - expect(OpenWeatherMap.default).toBeCalledWith({ - apiKey: 'api-key' - }); - const MockedOpenWeather = vi.mocked(OpenWeatherMap.default); - const openWeather = MockedOpenWeather.mock.results[0].value; - expect(openWeather[`getCurrentWeatherBy${by}`]).toBeCalledTimes(1); - }); - - it('gets the 3h forecast', async () => { - expect(await weather.getThreeHourForecast()).to.deep.equal(threeHour); - expect(OpenWeatherMap.default).toBeCalledWith({ - apiKey: 'api-key' - }); - const MockedOpenWeather = vi.mocked(OpenWeatherMap.default); - const openWeather = MockedOpenWeather.mock.results[0].value; - expect(openWeather[`getThreeHourForecastBy${by}`]).toBeCalledTimes(1); - }); - - it('only calls the api once', async () => { - expect(await weather.getCurrentWeather()).to.deep.equal(current); - expect(await weather.getCurrentWeather()).to.deep.equal(current); - expect(await weather.getThreeHourForecast()).to.deep.equal(threeHour); - expect(await weather.getThreeHourForecast()).to.deep.equal(threeHour); - expect(OpenWeatherMap.default).toBeCalledWith({ - apiKey: 'api-key' - }); - const MockedOpenWeather = vi.mocked(OpenWeatherMap.default); - const openWeather = MockedOpenWeather.mock.results[0].value; - expect(openWeather[`getCurrentWeatherBy${by}`]).toBeCalledTimes(1); - expect(openWeather[`getThreeHourForecastBy${by}`]).toBeCalledTimes(1); - }); - } - ); - -describe('invalid weather object', () => { - beforeEach(() => { - vi.clearAllMocks(); - weather = new Weather({ - key: 'api-key', - cityid: 1 - }); - weather['locationType'] = null; - }); - - it('throws an exception when getCurrentWeather is called', async () => { - await expect(weather.getCurrentWeather()).rejects.toThrow(/location type/); - }); - - it('throws an exception when getThreeHourForecast is called', async () => { - await expect(weather.getThreeHourForecast()).rejects.toThrow(/location type/); - }); -});