Procedurally generates a radio weather report

exhaustive tests for sequencer function

Changed files
+101 -15
src
test
+1 -1
package.json
··· 21 "main": "distribution/index.js", 22 "scripts": { 23 "build": "tsc", 24 - "start": "node distribution/index.js", 25 "test": "vitest" 26 }, 27 "dependencies": {
··· 21 "main": "distribution/index.js", 22 "scripts": { 23 "build": "tsc", 24 + "start": "node distribution/src/index.js", 25 "test": "vitest" 26 }, 27 "dependencies": {
+19 -13
src/sequencer.ts
··· 1 - import { OpenWeatherAPI, type CurrentWeather } from 'openweather-api-node'; 2 import { voiceLines } from './voice.js'; 3 import type { Config } from './index.js'; 4 import type { Voice } from './voice.js'; ··· 19 return arr[Math.floor(Math.random() * arr.length)]; 20 } 21 22 - function resolveSide(side: string, currentWeather: CurrentWeather) { 23 if (!side.startsWith('weather')) { 24 return side.includes('.') ? parseFloat(side) : parseInt(side); 25 } ··· 30 return typeof w === 'object' ? JSON.stringify(w) : w as (string | number); 31 } 32 33 - function conditionIsMet(condition: string | undefined, currentWeather: CurrentWeather): boolean { 34 if (typeof condition !== 'string') { 35 return true; 36 } 37 const [lhs, relational, rhs] = condition.split(' '); 38 - const lhsResolved = resolveSide(lhs, currentWeather); 39 - const rhsResolved = resolveSide(rhs, currentWeather); 40 switch (relational) { 41 case '=': 42 case '==': ··· 56 } 57 } 58 59 - function resolveMacro(str: string, currentWeather: CurrentWeather): string[] { 60 if (str.startsWith('%')) { 61 const [profile, subject] = str.substring(1).split(' ', 2); 62 const voiceProfile: Voice = config.voices[profile]; ··· 64 subject.split('.').forEach(t => resolvedSubject = resolvedSubject[t]); 65 return voiceLines(voiceProfile, resolvedSubject); 66 } 67 - else if (str.startsWith('$')) { 68 - return null; 69 - } 70 return [str]; 71 } 72 73 - function processSequence(sequence: Sequence, currentWeather: CurrentWeather): string[] { 74 const tracks = sequence.tracks; 75 return tracks.map(t => resolveMacro(t, currentWeather)).flat().filter(t => t !== null); 76 } 77 78 - function processSegment(segment: SegmentName, currentWeather: CurrentWeather): string[] { 79 if (!(segment in config.segments)) { 80 return (config.sequences[segment].conditions || []).every(c => conditionIsMet(c, currentWeather)) ? processSequence(config.sequences[segment], currentWeather) : []; 81 } ··· 89 async function Sequencer(conf: Config): Promise<string[]> { 90 config = conf; 91 const weather = new OpenWeatherAPI(conf.weather); 92 - const currentWeather = await weather.getCurrent(); 93 - console.log(JSON.stringify(currentWeather)); 94 const sequence: string[] = []; 95 const program: SegmentName[] = selectOne(conf.programs); 96 for (let i = 0; i < program.length; i++) {
··· 1 + import { OpenWeatherAPI, type DailyWeather } from 'openweather-api-node'; 2 import { voiceLines } from './voice.js'; 3 import type { Config } from './index.js'; 4 import type { Voice } from './voice.js'; ··· 19 return arr[Math.floor(Math.random() * arr.length)]; 20 } 21 22 + function resolveSide(side: string, currentWeather: DailyWeather) { 23 if (!side.startsWith('weather')) { 24 return side.includes('.') ? parseFloat(side) : parseInt(side); 25 } ··· 30 return typeof w === 'object' ? JSON.stringify(w) : w as (string | number); 31 } 32 33 + function notNotANumber(something: number | string, defaultVal: string) { 34 + if (typeof something === 'string' || !isNaN(something)) { 35 + return something; 36 + } 37 + return defaultVal; 38 + } 39 + 40 + function conditionIsMet(condition: string | undefined, currentWeather: DailyWeather): boolean { 41 if (typeof condition !== 'string') { 42 return true; 43 } 44 const [lhs, relational, rhs] = condition.split(' '); 45 + if (lhs === undefined || relational === undefined || rhs === undefined) { 46 + throw new Error(`Condition "${condition}" is not in the correct format`); 47 + } 48 + const lhsResolved = notNotANumber(resolveSide(lhs, currentWeather), lhs); 49 + const rhsResolved = notNotANumber(resolveSide(rhs, currentWeather), rhs); 50 switch (relational) { 51 case '=': 52 case '==': ··· 66 } 67 } 68 69 + function resolveMacro(str: string, currentWeather: DailyWeather): string[] { 70 if (str.startsWith('%')) { 71 const [profile, subject] = str.substring(1).split(' ', 2); 72 const voiceProfile: Voice = config.voices[profile]; ··· 74 subject.split('.').forEach(t => resolvedSubject = resolvedSubject[t]); 75 return voiceLines(voiceProfile, resolvedSubject); 76 } 77 return [str]; 78 } 79 80 + function processSequence(sequence: Sequence, currentWeather: DailyWeather): string[] { 81 const tracks = sequence.tracks; 82 return tracks.map(t => resolveMacro(t, currentWeather)).flat().filter(t => t !== null); 83 } 84 85 + function processSegment(segment: SegmentName, currentWeather: DailyWeather): string[] { 86 if (!(segment in config.segments)) { 87 return (config.sequences[segment].conditions || []).every(c => conditionIsMet(c, currentWeather)) ? processSequence(config.sequences[segment], currentWeather) : []; 88 } ··· 96 async function Sequencer(conf: Config): Promise<string[]> { 97 config = conf; 98 const weather = new OpenWeatherAPI(conf.weather); 99 + const currentWeather = await weather.getToday(); 100 const sequence: string[] = []; 101 const program: SegmentName[] = selectOne(conf.programs); 102 for (let i = 0; i < program.length; i++) {
+81 -1
test/sequencer.test.ts
··· 9 return { 10 OpenWeatherAPI: vi.fn().mockImplementation((_) => { 11 return { 12 - getCurrent: vi.fn(() => { 13 return { 14 "lat": 43.0748, 15 "lon": -89.3838, ··· 105 voices: {}, 106 weather: dummyWeather 107 })).to.be.ordered.members(['seq1.flac', 'seq1.flac']); 108 }); 109 }); 110
··· 9 return { 10 OpenWeatherAPI: vi.fn().mockImplementation((_) => { 11 return { 12 + getToday: vi.fn(() => { 13 return { 14 "lat": 43.0748, 15 "lon": -89.3838, ··· 105 voices: {}, 106 weather: dummyWeather 107 })).to.be.ordered.members(['seq1.flac', 'seq1.flac']); 108 + }); 109 + 110 + it('throws an error on invalid conditions', async () => { 111 + await expect(Sequencer({ 112 + programs: [['sequence 1']], 113 + segments: { 114 + }, 115 + sequences: { 116 + 'sequence 1': { 117 + 'conditions': ['100'], 118 + 'tracks': [ 119 + 'seq1.flac' 120 + ] 121 + } 122 + }, 123 + voices: {}, 124 + weather: dummyWeather 125 + })).rejects.toThrow(/not in the correct format/); 126 + 127 + await expect(Sequencer({ 128 + programs: [['sequence 1']], 129 + segments: { 130 + }, 131 + sequences: { 132 + 'sequence 1': { 133 + 'conditions': ['1 ~ 2'], 134 + 'tracks': [ 135 + 'seq1.flac' 136 + ] 137 + } 138 + }, 139 + voices: {}, 140 + weather: dummyWeather 141 + })).rejects.toThrow(/Unsupported relational operator/); 142 + }); 143 + 144 + it('can stringify conditions', async () => { 145 + expect(await Sequencer({ 146 + programs: [['sequence 1']], 147 + segments: { 148 + }, 149 + sequences: { 150 + 'sequence 1': { 151 + 'conditions': ['weather.feelsLike = {"cur":55.31}'], 152 + 'tracks': [ 153 + 'seq1.flac' 154 + ] 155 + } 156 + }, 157 + voices: {}, 158 + weather: dummyWeather 159 + })).to.be.ordered.members(['seq1.flac']); 160 + }); 161 + 162 + it('can parse voice macros', async () => { 163 + expect(await Sequencer({ 164 + programs: [['sequence 1']], 165 + segments: { 166 + }, 167 + sequences: { 168 + 'sequence 1': { 169 + 'tracks': [ 170 + '%alice weather.temp.max' 171 + ] 172 + } 173 + }, 174 + voices: { 175 + "alice": { 176 + "directory": "alice/", 177 + "extension": "flac" 178 + } 179 + }, 180 + weather: dummyWeather 181 + })).to.be.ordered.members([ 182 + 'alice/fifty.flac', 183 + 'alice/eight.flac', 184 + 'alice/point.flac', 185 + 'alice/zero.flac', 186 + 'alice/one.flac' 187 + ]); 188 }); 189 }); 190