Something went wrong. Try again.
Live STIB-MIVB departures on the Pebble Time 2. It covers the bus, tram, and metro network of Brussels
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173var test = require('node:test');var assert = require('node:assert');var wire = require('../src/pkjs/wire.js');var api = require('../src/pkjs/api.js');var favs = require('../src/pkjs/favs.js');
var board = { stale: false, stops: [ { id: '6419', name: { fr: 'BOTANIQUE', nl: 'KRUIDTUIN' }, distance_m: 120, rows: [ { line: '62', kind: 'tram', color: 'EA4F80', text_color: '000000', destination: { fr: 'SAINT-LAMBERT', nl: 'SINT-LAMBERTUS' }, in_seconds: [90, 660] } ] } ]};
function i16(blob, off) { var v = blob[off] | (blob[off + 1] << 8); return v - (v >= 0x8000 ? 0x10000 : 0);}
function blobStr(blob, off) { var s = ''; for (var i = 0; blob[off + i] !== 0; i++) { s += String.fromCharCode(blob[off + i]); } return s;}
test('gcolor8 quantizes hex to argb8', function () { assert.equal(wire.gcolor8('FFFFFF'), 0xFF); assert.equal(wire.gcolor8('000000'), 0xC0); assert.equal(wire.gcolor8('FF0000'), 0xF0); assert.equal(wire.gcolor8('00FF00'), 0xCC); assert.equal(wire.gcolor8('0000FF'), 0xC3);});
test('boardToMessages streams header row done', function () { var msgs = wire.boardToMessages(board, 'nl', 0); assert.equal(msgs.length, 2); assert.deepEqual(msgs[0], { KIND: 1, PAGE: 0, STOP_INDEX: 0, STOP_COUNT: 1, STOP_ID: '6419', STOP_NAME: 'KRUIDTUIN', STOP_DISTANCE: 120, BOARD_ROWS: wire.packRows(board.stops[0].rows, 'nl') }); assert.equal(blobStr(msgs[0].BOARD_ROWS, 2 + 11), 'SINT-LAMBERTUS'); assert.deepEqual(msgs[1], { KIND: 3, PAGE: 0, ERROR: 0 });});
test('boardToMessages stamps every message with its page', function () { var board = { stale: false, stops: [{ id: 'S1', name: { fr: 'X', nl: 'X' }, distance_m: 10, rows: [{ line: 'IC', kind: 'train', color: '1A5FB4', text_color: 'FFFFFF', destination: { fr: 'D', nl: 'D' }, in_seconds: [60] }] }] }; var msgs = wire.boardToMessages(board, 'fr', 1); assert.ok(msgs.length > 0); msgs.forEach(function (m) { assert.strictEqual(m.PAGE, 1, 'every message carries PAGE, KIND ' + m.KIND); });});
test('boardToMessages carries flags bit math', function () { function row(extra) { return Object.assign({ line: '62', kind: 'tram', color: '000000', text_color: 'FFFFFF', destination: { fr: 'A', nl: 'A' }, in_seconds: [] }, extra); } var stop = { id: '1', name: { fr: 'S', nl: 'S' }, distance_m: 0, rows: [ row({}), row({ disrupted: true }), row({ end_of_service: true }), row({ disrupted: true, end_of_service: true }) ] }; var msgs = wire.boardToMessages({ stale: false, stops: [stop] }, 'nl', 0); var blob = msgs[0].BOARD_ROWS; assert.equal(blob[2 + 0 * 57 + 8] >> 3, 0); assert.equal(blob[2 + 1 * 57 + 8] >> 3, 1); assert.equal(blob[2 + 2 * 57 + 8] >> 3, 2); assert.equal(blob[2 + 3 * 57 + 8] >> 3, 3);});
test('boardToMessages no longer sends stops away or direction', function () { var stop = { id: '6419', name: { fr: 'B', nl: 'B' }, distance_m: 10, direction: { fr: 'D', nl: 'D' }, rows: [ { line: '62', kind: 'tram', color: 'EA4F80', text_color: '000000', destination: { fr: 'X', nl: 'X' }, in_seconds: [60], stops_away: 3 } ] }; var msgs = wire.boardToMessages({ stale: false, stops: [stop] }, 'fr', 0); assert.equal(msgs.length, 2); assert.strictEqual(msgs[0].STOP_DIRECTION, undefined);});
test('lineToMessages produces header then strip entries with marker mapping', function () { var detail = { line: '62', kind: 'tram', color: 'EA4F80', text_color: '000000', destination: { fr: 'SAINT-LAMBERT', nl: 'SINT-LAMBERTUS' }, in_seconds: [90, 660], disruption: null, strip: [ { id: 'a', name: { fr: 'A', nl: 'A-nl' }, you: false, vehicle_after_m: null }, { id: 'b', name: { fr: 'B', nl: 'B-nl' }, you: false, vehicle_after_m: 200 }, { id: 'c', name: { fr: 'C', nl: 'C-nl' }, you: true, vehicle_after_m: null } ] }; var msgs = wire.lineToMessages(detail, 'en', 'nl'); assert.equal(msgs.length, 1); assert.deepEqual(msgs[0], { KIND: 6, ROW_LINE: '62', ROW_KIND: 0, ROW_COLOR: wire.gcolor8('EA4F80'), ROW_TEXT_COLOR: wire.gcolor8('000000'), ROW_DEST: 'SINT-LAMBERTUS', ROW_MIN1: 1, ROW_MIN2: 11, LINE_DISRUPTION: '', LINE_STRIPS: wire.packStrips(detail.strip, 'nl'), LINE_STATUS: 0, LINE_SHAPE: [] }); var strips = msgs[0].LINE_STRIPS; assert.equal(strips[1], 3); assert.equal(blobStr(strips, 2), 'A-nl'); assert.equal(strips[2 + 32], 0); assert.equal(blobStr(strips, 2 + 33), 'B-nl'); assert.equal(strips[2 + 33 + 32], 1); assert.equal(blobStr(strips, 2 + 66), 'C-nl'); assert.equal(strips[2 + 66 + 32], 2);});
test('lineToMessages truncates strip at 8 in LINE_STRIPS', function () { var strip = []; for (var i = 0; i < 11; i++) { strip.push({ id: String(i), name: { fr: 'S' + i, nl: 'S' + i }, you: false, vehicle_after_m: null }); } var detail = { line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'X', nl: 'X' }, in_seconds: [], disruption: null, strip: strip }; var msgs = wire.lineToMessages(detail, 'en', 'fr'); var strips = msgs[0].LINE_STRIPS; assert.equal(strips[1], 8); assert.equal(strips.length, 2 + 8 * 33); assert.equal(blobStr(strips, 2 + 7 * 33), 'S7');});
test('lineToMessages picks disruption text labelLang then fr then nl then empty', function () { var base = { line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'X', nl: 'X' }, in_seconds: [], strip: [] }; var withEn = Object.assign({}, base, { disruption: { en: 'delay EN', fr: 'delay FR', nl: 'delay NL' } }); assert.equal(wire.lineToMessages(withEn, 'en', 'fr')[0].LINE_DISRUPTION, 'delay EN');
var noEn = Object.assign({}, base, { disruption: { fr: 'delay FR', nl: 'delay NL' } }); assert.equal(wire.lineToMessages(noEn, 'en', 'fr')[0].LINE_DISRUPTION, 'delay FR');
var onlyNl = Object.assign({}, base, { disruption: { nl: 'delay NL' } }); assert.equal(wire.lineToMessages(onlyNl, 'en', 'fr')[0].LINE_DISRUPTION, 'delay NL');
var none = Object.assign({}, base, { disruption: null }); assert.equal(wire.lineToMessages(none, 'en', 'fr')[0].LINE_DISRUPTION, '');});
test('lineToMessages caps an oversized disruption at 255 UTF-8 bytes', function () { var base = { line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'X', nl: 'X' }, in_seconds: [], strip: [] }; var big = Object.assign({}, base, { disruption: { en: 'x'.repeat(800) } }); var text = wire.lineToMessages(big, 'en', 'fr')[0].LINE_DISRUPTION; assert.equal(text, 'x'.repeat(255)); assert.equal(Buffer.byteLength(text, 'utf8'), 255);
var accented = Object.assign({}, base, { disruption: { en: 'é'.repeat(200) } }); text = wire.lineToMessages(accented, 'en', 'fr')[0].LINE_DISRUPTION; var bytes = Buffer.from(text, 'utf8'); assert.equal(bytes.length, 254); assert.equal(text, 'é'.repeat(127)); assert.equal(bytes.toString('utf8'), text);
var cjk = Object.assign({}, base, { disruption: { en: '駅'.repeat(100) } }); text = wire.lineToMessages(cjk, 'en', 'fr')[0].LINE_DISRUPTION; bytes = Buffer.from(text, 'utf8'); assert.equal(bytes.length, 255); assert.equal(text, '駅'.repeat(85)); assert.equal(bytes.toString('utf8'), text);
var mixed = Object.assign({}, base, { disruption: { en: 'x'.repeat(254) + '駅駅' } }); text = wire.lineToMessages(mixed, 'en', 'fr')[0].LINE_DISRUPTION; bytes = Buffer.from(text, 'utf8'); assert.ok(bytes.length <= 255); assert.equal(text, 'x'.repeat(254)); assert.equal(bytes.toString('utf8'), text);
var small = Object.assign({}, base, { disruption: { en: 'short été' } }); assert.equal(wire.lineToMessages(small, 'en', 'fr')[0].LINE_DISRUPTION, 'short été');});
test('boardToMessages flags stale and empty', function () { var msgs = wire.boardToMessages({ stale: true, stops: board.stops }, 'fr', 0); assert.equal(msgs[1].ERROR, 3); var empty = wire.boardToMessages({ stale: false, stops: [] }, 'fr', 0); assert.deepEqual(empty, [{ KIND: 3, PAGE: 0, ERROR: 4 }]);});
test('settingsMessage packs config', function () { assert.deepEqual( wire.settingsMessage({ labelLang: 'nl', vibrate: false }), { KIND: 5, CFG_VIBRATE: 0, CFG_LANG: 2 } );});
test('buildUrl composes query', function () { assert.equal( api.buildUrl('https://s.example', { lat: 50.5, lon: 4.25 }, 0), 'https://s.example/v1/transit?lat=50.5&lon=4.25' ); assert.equal(api.buildUrl('https://s.example', null, 0), 'https://s.example/v1/transit?');});
test('buildUrl no longer carries favs', function () { var url = api.buildUrl('http://x', { coords: { latitude: 50.85, longitude: 4.35 } }, 0); assert.ok(url.indexOf('favs=') === -1);});
test('buildUrl targets the endpoint for each page', function () { var pos = { lat: 50.85, lon: 4.35 }; assert.ok(api.buildUrl('http://x', pos, 0).indexOf('/v1/transit?') !== -1); assert.ok(api.buildUrl('http://x', pos, 1).indexOf('/v1/trains?') !== -1); assert.ok(api.buildUrl('http://x', pos, 2).indexOf('/v1/villo?') !== -1);});
test('buildUrl keeps the position on every page', function () { var pos = { lat: 50.85, lon: 4.35 }; [0, 1, 2].forEach(function (page) { var url = api.buildUrl('http://x', pos, page); assert.ok(url.indexOf('lat=50.85') !== -1); assert.ok(url.indexOf('lon=4.35') !== -1); });});
test('boardToMessages stamps strictly from its own page argument, not a prior call', function () { var first = wire.boardToMessages(board, 'nl', 0); var second = wire.boardToMessages(board, 'nl', 1); var third = wire.boardToMessages(board, 'nl', 0); first.forEach(function (m) { assert.strictEqual(m.PAGE, 0); }); second.forEach(function (m) { assert.strictEqual(m.PAGE, 1); }); third.forEach(function (m) { assert.strictEqual(m.PAGE, 0); });});
test('pageOrTransit falls back for an unknown page', function () { assert.strictEqual(api.pageOrTransit(0), 0); assert.strictEqual(api.pageOrTransit(1), 1); assert.strictEqual(api.pageOrTransit(2), 2); assert.strictEqual(api.pageOrTransit(9), 0); assert.strictEqual(api.pageOrTransit(undefined), 0);});
test('buildLineUrl composes query with encoding', function () { assert.equal( api.buildLineUrl('https://s.example', '62', '6419'), 'https://s.example/v1/line?line=62&to=6419' ); assert.equal( api.buildLineUrl('https://s.example', '2/3 bis', 'a b'), 'https://s.example/v1/line?line=2%2F3%20bis&to=a%20b' );});
test('buildLineUrl appends dest only when non-empty', function () { assert.equal( api.buildLineUrl('https://s.example', '34', '2290', 'ROND-PT SOUVERAIN'), 'https://s.example/v1/line?line=34&to=2290&dest=ROND-PT%20SOUVERAIN' ); assert.equal( api.buildLineUrl('https://s.example', '34', '2290', ''), 'https://s.example/v1/line?line=34&to=2290' ); assert.equal( api.buildLineUrl('https://s.example', '34', '2290', undefined), 'https://s.example/v1/line?line=34&to=2290' );});
test('boardToMessages caps stops and rows at MAX_STOPS and MAX_ROWS', function () { var stops = []; for (var i = 0; i < 33; i++) { var rows = []; for (var j = 0; j < 13; j++) { rows.push({ line: String(j), kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'A', nl: 'A' }, in_seconds: [60] }); } stops.push({ id: String(i), name: { fr: 'S' + i, nl: 'S' + i }, distance_m: 100, rows: rows }); } var msgs = wire.boardToMessages({ stale: false, stops: stops }, 'nl', 0); var kind1s = msgs.filter(function (m) { return m.KIND === 1; }); assert.equal(kind1s.length, 12); assert.equal(kind1s[0].STOP_COUNT, 12); var blob = kind1s[0].BOARD_ROWS; assert.equal(blob[1], 12); assert.equal(blob.length, 2 + 12 * 57);});
test('boardToMessages handles null distance_m as 0', function () { var msgs = wire.boardToMessages({ stale: false, stops: [{ id: '123', name: { fr: 'X', nl: 'X' }, distance_m: null, rows: [] }]}, 'nl', 0); assert.equal(msgs[0].STOP_DISTANCE, 0);});
test('boardToMessages handles absent in_seconds as -1', function () { var partial = wire.boardToMessages({ stale: false, stops: [{ id: '123', name: { fr: 'X', nl: 'X' }, distance_m: 0, rows: [{ line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'A', nl: 'A' }, in_seconds: [90] }] }]}, 'nl', 0); var pblob = partial[0].BOARD_ROWS; assert.equal(i16(pblob, 2 + 43), 1); assert.equal(i16(pblob, 2 + 45), -1);
var empty = wire.boardToMessages({ stale: false, stops: [{ id: '123', name: { fr: 'X', nl: 'X' }, distance_m: 0, rows: [{ line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'A', nl: 'A' }, in_seconds: [] }] }]}, 'nl', 0); var eblob = empty[0].BOARD_ROWS; assert.equal(i16(eblob, 2 + 43), -1); assert.equal(i16(eblob, 2 + 45), -1);});
test('boardToMessages maps train kind to ROW_KIND 3', function () { var msgs = wire.boardToMessages({ stale: false, stops: [{ id: '123', name: { fr: 'X', nl: 'X' }, distance_m: 0, rows: [{ line: 'IC1234', kind: 'train', color: '1A5FB4', text_color: 'FFFFFF', destination: { fr: 'Destination', nl: 'Destination' }, in_seconds: [120] }] }]}, 'nl', 0); assert.equal(msgs[0].BOARD_ROWS[2 + 8] & 7, 3);});
test('boardToMessages maps bike kind to ROW_KIND 4', function () { var msgs = wire.boardToMessages({ stale: false, stops: [{ id: '123', name: { fr: 'X', nl: 'X' }, distance_m: 0, rows: [{ line: 'V', kind: 'bike', color: '8FF0A4', text_color: '000000', destination: { fr: '10 velos', nl: '10 fietsen' }, in_seconds: [] }] }]}, 'nl', 0); assert.equal(msgs[0].BOARD_ROWS[2 + 8] & 7, 4);});
test('boardToMessages scopes train fields to train rows and bike counts to stops', function () { var msgs = wire.boardToMessages({ stale: false, stops: [{ id: 'SNCB:1', name: { fr: 'X', nl: 'X' }, distance_m: 0, rows: [{ line: 'IC', kind: 'train', color: '1A5FB4', text_color: 'FFFFFF', destination: { fr: 'Leuven', nl: 'Leuven' }, in_seconds: [120], platform: '3', delay_seconds: 60 }] }, { id: 'VILLO:1', name: { fr: 'X', nl: 'X' }, distance_m: 0, bikes_mechanical: 4, bikes_electrical: 2, docks: 9, rows: [{ line: 'V', kind: 'bike', color: '8FF0A4', text_color: '000000', destination: { fr: '6 velos', nl: '6 fietsen' }, in_seconds: [] }] }]}, 'fr', 0); var stops = msgs.filter(function (m) { return m.KIND === 1; }); var trainBlob = stops[0].BOARD_ROWS; assert.equal(blobStr(trainBlob, 2 + 47), '3'); assert.equal(i16(trainBlob, 2 + 55), 60); assert.equal(stops[0].VILLO_MECHANICAL, undefined); assert.equal(stops[1].VILLO_MECHANICAL, 4); assert.equal(stops[1].VILLO_ELECTRICAL, 2); assert.equal(stops[1].VILLO_DOCKS, 9); var bikeBlob = stops[1].BOARD_ROWS; assert.equal(bikeBlob[2 + 47], 0); assert.equal(i16(bikeBlob, 2 + 55), 0);});
test('boardToMessages omits absent scoped fields', function () { var msgs = wire.boardToMessages(board, 'fr', 0); assert.equal(msgs[0].VILLO_MECHANICAL, undefined); var blob = msgs[0].BOARD_ROWS; for (var off = 2 + 47; off <= 2 + 56; off++) { assert.equal(blob[off], 0); }});
test('boardToMessages keeps a zero delay distinct from an absent one', function () { var msgs = wire.boardToMessages({ stale: false, stops: [{ id: '1', name: { fr: 'X', nl: 'X' }, distance_m: 0, rows: [{ line: 'IC', kind: 'train', color: '1A5FB4', text_color: 'FFFFFF', destination: { fr: 'A', nl: 'A' }, in_seconds: [120], delay_seconds: 0 }] }]}, 'fr', 0); assert.equal(i16(msgs[0].BOARD_ROWS, 2 + 55), 0);});
test('messageKeys expose only watch wire keys', function () { var keys = require('../package.json').pebble.messageKeys; assert.deepEqual(keys, [ 'KIND', 'STOP_INDEX', 'STOP_COUNT', 'STOP_ID', 'STOP_NAME', 'STOP_DISTANCE', 'ROW_LINE', 'ROW_KIND', 'ROW_COLOR', 'ROW_TEXT_COLOR', 'ROW_DEST', 'ROW_MIN1', 'ROW_MIN2', 'ERROR', 'CFG_VIBRATE', 'CFG_LANG', 'LINE_DISRUPTION', 'REQ_STOP_ID', 'REQ_LINE', 'LINE_STATUS', 'LINE_SHAPE', 'REQ_DEST', 'VILLO_MECHANICAL', 'VILLO_ELECTRICAL', 'VILLO_DOCKS', 'PAGE', 'REQ_PAGE', 'BOARD_ROWS', 'LINE_STRIPS', 'FAV_RESULT' ]);});
test('boardToMessages no longer sends rain', function () { var msgs = wire.boardToMessages({ stale: false, rain_in_minutes: 8, stops: [] }, 'fr', 0); var done = msgs.filter(function (m) { return m.KIND === 3; })[0]; assert.strictEqual(done.RAIN_MINUTES, undefined);});
test('line404ToMessages produces an unknown-status header dict', function () { var msgs = wire.line404ToMessages('62'); assert.equal(msgs.length, 1); assert.deepEqual(msgs[0], { KIND: 6, LINE_STATUS: 1, ROW_LINE: '62', ROW_KIND: 0, ROW_COLOR: wire.gcolor8('555555'), ROW_TEXT_COLOR: wire.gcolor8('FFFFFF'), ROW_DEST: '', ROW_MIN1: -1, ROW_MIN2: -1 });});
test('packShape returns empty array when points absent', function () { assert.deepEqual(wire.packShape(undefined), []); assert.deepEqual(wire.packShape([]), []);});
test('packShape centers a degenerate single point', function () { assert.deepEqual(wire.packShape([[50.85, 4.35]]), [128, 128]);});
test('packShape normalizes points to a 0..255 bounding box', function () { var points = [[50.0, 4.0], [50.0, 5.0], [51.0, 4.0], [51.0, 5.0]]; assert.deepEqual(wire.packShape(points), [0, 255, 255, 255, 0, 0, 255, 0]);});
test('packShape caps input at 24 points before packing', function () { var points = []; for (var i = 0; i < 30; i++) { points.push([50.0 + i * 0.01, 4.0 + i * 0.01]); } assert.equal(wire.packShape(points).length, 48);});
test('lineToMessages LINE_STATUS is 0 and LINE_SHAPE packs the detail shape', function () { var detail = { line: '62', kind: 'tram', color: '000000', text_color: 'FFFFFF', destination: { fr: 'X', nl: 'X' }, in_seconds: [], disruption: null, strip: [], shape: [[50.0, 4.0], [51.0, 5.0]] }; var msgs = wire.lineToMessages(detail, 'en', 'fr'); assert.equal(msgs[0].LINE_STATUS, 0); assert.deepEqual(msgs[0].LINE_SHAPE, [0, 255, 255, 0]);});
test('boardToMessages defaults a row without in_seconds to -1 minutes', function () { var msgs = wire.boardToMessages({ stale: false, stops: [{ id: '123', name: { fr: 'X', nl: 'X' }, distance_m: 0, rows: [{ line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'A', nl: 'A' } }] }]}, 'nl', 0); assert.equal(i16(msgs[0].BOARD_ROWS, 2 + 43), -1); assert.equal(i16(msgs[0].BOARD_ROWS, 2 + 45), -1);});
test('lineToMessages defaults a detail without in_seconds to -1 minutes', function () { var msgs = wire.lineToMessages({ line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'X', nl: 'X' }, disruption: null, strip: [] }, 'en', 'fr'); assert.equal(msgs[0].ROW_MIN1, -1); assert.equal(msgs[0].ROW_MIN2, -1);});
function freshQueue() { delete require.cache[require.resolve('../src/pkjs/queue.js')]; return require('../src/pkjs/queue.js');}
function fakePebble() { var state = { pending: [], delivered: [] }; global.Pebble = { sendAppMessage: function (dict, ack, nack) { state.pending.push({ dict: dict, ack: ack, nack: nack }); } }; state.ack = function () { var item = state.pending.shift(); state.delivered.push(item.dict); item.ack(); }; state.nack = function () { var item = state.pending.shift(); item.nack(); }; return state;}
test('queue delivers pushed dicts in order across pushes', function () { var phone = fakePebble(); var queue = freshQueue(); var a = { KIND: 1 }; var b = { KIND: 2 }; var c = { KIND: 3 }; queue.push([a, b]); queue.push([c]); assert.equal(phone.pending.length, 1); phone.ack(); phone.ack(); phone.ack(); assert.deepEqual(phone.delivered, [a, b, c]); assert.equal(phone.pending.length, 0); delete global.Pebble;});
test('queue retries a nacked dict once then drops it', function () { var phone = fakePebble(); var queue = freshQueue(); var a = { KIND: 1 }; var b = { KIND: 2 }; queue.push([a, b]); phone.nack(); assert.equal(phone.pending[0].dict, a); phone.nack(); assert.equal(phone.pending[0].dict, b); phone.ack(); assert.deepEqual(phone.delivered, [b]); delete global.Pebble;});
test('queue recovers after a nacked retry succeeds', function () { var phone = fakePebble(); var queue = freshQueue(); var a = { KIND: 1 }; var b = { KIND: 2 }; queue.push([a]); phone.nack(); phone.ack(); queue.push([b]); phone.ack(); assert.deepEqual(phone.delivered, [a, b]); delete global.Pebble;});
test('queue survives a flood without reordering or duplicating', function () { var phone = fakePebble(); var queue = freshQueue(); var dicts = []; for (var i = 0; i < 10000; i++) { dicts.push({ KIND: 2, SEQ: i }); } queue.push(dicts); var guard = 0; while (phone.pending.length && guard < 30000) { if (guard % 7 === 3) { phone.nack(); } else { phone.ack(); } guard++; } assert.equal(phone.pending.length, 0); var seen = -1; phone.delivered.forEach(function (d) { assert.ok(d.SEQ > seen); seen = d.SEQ; }); assert.ok(phone.delivered.length > 9000); delete global.Pebble;});
test('packShape stays capped under tens of thousands of points', function () { var points = []; for (var i = 0; i < 40000; i++) { points.push([50.0 + (i % 977) * 0.0001, 4.0 + (i % 631) * 0.0001]); } var out = wire.packShape(points); assert.equal(out.length, 48); out.forEach(function (v) { assert.ok(Number.isInteger(v) && v >= 0 && v <= 255); });});
test('boardToMessages caps an oversized board with oversized rows', function () { var stops = []; for (var i = 0; i < 500; i++) { var rows = []; for (var j = 0; j < 40; j++) { rows.push({ line: 'L' + j, kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'D'.repeat(200), nl: 'D'.repeat(200) }, in_seconds: [j * 60] }); } stops.push({ id: 'id' + i, name: { fr: 'N'.repeat(200), nl: 'N'.repeat(200) }, distance_m: i, rows: rows }); } var msgs = wire.boardToMessages({ stale: false, stops: stops }, 'fr', 0); var kind1s = msgs.filter(function (m) { return m.KIND === 1; }); assert.equal(kind1s.length, 12); kind1s.forEach(function (s) { assert.equal(s.BOARD_ROWS.length, 2 + 12 * 57); assert.equal(s.BOARD_ROWS[1], 12); }); assert.equal(msgs[msgs.length - 1].KIND, 3);});
function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; var t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
test('boardToMessages survives a malformed row flood without throwing', function () { var rand = mulberry32(0x51BB1E25); for (var iter = 0; iter < 2000; iter++) { var rows = []; var rowCount = Math.floor(rand() * 5); for (var j = 0; j < rowCount; j++) { var row = { line: String(Math.floor(rand() * 100)), kind: ['tram', 'metro', 'bus', 'train', 'bike', 'zeppelin'][Math.floor(rand() * 6)], color: '000000', text_color: 'FFFFFF', destination: { fr: 'A', nl: 'A' } }; var pick = rand(); if (pick < 0.25) { row.in_seconds = []; } else if (pick < 0.5) { row.in_seconds = [Math.floor(rand() * 100000)]; } else if (pick < 0.75) { row.in_seconds = [Math.floor(rand() * 100000), Math.floor(rand() * 100000)]; } if (rand() < 0.3) { row.stops_away = Math.floor(rand() * 40); } if (rand() < 0.3) { row.disrupted = true; } if (rand() < 0.3) { row.end_of_service = true; } if (rand() < 0.3) { row.drift_seconds = Math.floor(rand() * 10000) - 5000; } rows.push(row); } var board = { stale: rand() < 0.5, stops: [{ id: 'x', name: { fr: 'X', nl: 'X' }, distance_m: rand() < 0.5 ? null : Math.floor(rand() * 5000), rows: rows }] }; var msgs = wire.boardToMessages(board, rand() < 0.5 ? 'fr' : 'nl', 0); msgs.forEach(function (m) { if (m.KIND === 1) { var blob = m.BOARD_ROWS; assert.equal(blob.length, 2 + blob[1] * 57); blob.forEach(function (b) { assert.ok(Number.isInteger(b) && b >= 0 && b <= 255); }); for (var r = 0; r < blob[1]; r++) { assert.ok(i16(blob, 2 + r * 57 + 43) >= -1); assert.ok(i16(blob, 2 + r * 57 + 45) >= -1); assert.ok(blob[2 + r * 57 + 8] >> 3 <= 3); } } }); }});
test('boardToMessages consumes the frozen server wire golden', function () { var golden = JSON.parse( require('fs').readFileSync(__dirname + '/../server/tests/wire-golden.json', 'utf8') ); var msgs = wire.boardToMessages(golden, 'fr', 0);
var stops = msgs.filter(function (m) { return m.KIND === 1; }); assert.strictEqual(stops.length, 3); var totalRows = 0; stops.forEach(function (s) { totalRows += s.BOARD_ROWS[1]; }); assert.strictEqual(totalRows, 4);
var trainBlob = stops.filter(function (s) { return s.STOP_ID === 'SNCB:8813003'; })[0].BOARD_ROWS; assert.strictEqual(trainBlob[2 + 8] & 7, 3); assert.strictEqual(blobStr(trainBlob, 2 + 47), '3'); assert.strictEqual(i16(trainBlob, 2 + 55), 120);
var villoStop = stops.filter(function (s) { return s.STOP_ID === 'VILLO:046'; })[0]; assert.strictEqual(villoStop.VILLO_MECHANICAL, 7); assert.strictEqual(villoStop.VILLO_ELECTRICAL, 3); assert.strictEqual(villoStop.VILLO_DOCKS, 5);
var tramBlob = stops.filter(function (s) { return s.STOP_ID === '6419'; })[0].BOARD_ROWS; assert.strictEqual(tramBlob[2 + 8] & 7, 0); assert.strictEqual(i16(tramBlob, 2 + 43), 1); assert.strictEqual(i16(tramBlob, 2 + 45), 5); assert.strictEqual(tramBlob[2 + 8] >> 3, 1); assert.strictEqual(tramBlob[2 + 47], 0); assert.strictEqual(i16(tramBlob, 2 + 55), 0);
var base = 2 + 57; assert.strictEqual(tramBlob[base + 8] >> 3, 2); assert.strictEqual(i16(tramBlob, base + 43), -1); assert.strictEqual(blobStr(tramBlob, base + 11), 'LAST BUS HAS GONE');});
test('packRows packs one 57-byte entry per row', function () { var rows = [{ line: '62', kind: 'tram', color: 'EA4F80', text_color: '000000', destination: { fr: 'SAINT-LAMBERT', nl: 'SINT-LAMBERTUS' }, in_seconds: [90, 660], disrupted: true }]; var blob = wire.packRows(rows, 'nl'); assert.equal(blob.length, 2 + 57); assert.equal(blob[0], 2); assert.equal(blob[1], 1); assert.equal(String.fromCharCode(blob[2], blob[3]), '62'); assert.equal(blob[4], 0); assert.equal(blob[2 + 8], 0 | (1 << 3)); assert.equal(blob[2 + 9], wire.gcolor8('EA4F80')); assert.equal(blob[2 + 10], wire.gcolor8('000000')); var dest = ''; for (var i = 0; blob[2 + 11 + i] !== 0; i++) { dest += String.fromCharCode(blob[2 + 11 + i]); } assert.equal(dest, 'SINT-LAMBERTUS'); assert.equal(blob[2 + 43] | (blob[2 + 44] << 8), 1); assert.equal(blob[2 + 45] | (blob[2 + 46] << 8), 11); assert.equal(blob[2 + 55] | (blob[2 + 56] << 8), 0);});
test('packRows encodes negative int16 and train fields', function () { var rows = [{ line: 'IC', kind: 'train', color: '1A5FB4', text_color: 'FFFFFF', destination: { fr: 'D', nl: 'D' }, in_seconds: [], platform: '4' }]; var blob = wire.packRows(rows, 'fr'); var min1 = blob[2 + 43] | (blob[2 + 44] << 8); assert.equal(min1 - (min1 >= 0x8000 ? 0x10000 : 0), -1); assert.equal(String.fromCharCode(blob[2 + 47]), '4'); var delay = blob[2 + 55] | (blob[2 + 56] << 8); assert.equal(delay - (delay >= 0x8000 ? 0x10000 : 0), -1);});
test('packRows truncates utf8 without splitting a sequence', function () { var rows = [{ line: '1', kind: 'bus', color: '000000', text_color: 'FFFFFF', destination: { fr: 'ÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉ', nl: 'X' }, in_seconds: [] }]; var blob = wire.packRows(rows, 'fr'); var last = 0; for (var i = 0; i < 32 && blob[2 + 11 + i] !== 0; i++) { last = i; } assert.ok(last < 31); var bytes = []; for (var j = 0; blob[2 + 11 + j] !== 0; j++) { bytes.push(blob[2 + 11 + j]); } assert.equal(bytes.length % 2, 0); assert.ok(bytes.length <= 31);});
test('boardToMessages emits one dict per stop plus terminal', function () { var msgs = wire.boardToMessages(board, 'nl', 0); assert.equal(msgs.length, 2); assert.equal(msgs[0].KIND, 1); assert.ok(Array.isArray(msgs[0].BOARD_ROWS)); assert.equal(msgs[0].BOARD_ROWS.length, 2 + 57); assert.deepEqual(msgs[1], { KIND: 3, PAGE: 0, ERROR: 0 });});
test('lineToMessages emits a single dict with packed strips', function () { var detail = { line: '62', kind: 'tram', color: 'EA4F80', text_color: '000000', destination: { fr: 'A', nl: 'A' }, in_seconds: [60], strip: [ { name: { fr: 'S1', nl: 'S1' }, you: true }, { name: { fr: 'S2', nl: 'S2' }, vehicle_after_m: 100 } ] }; var msgs = wire.lineToMessages(detail, 'fr', 'fr'); assert.equal(msgs.length, 1); assert.ok(Array.isArray(msgs[0].LINE_STRIPS)); assert.equal(msgs[0].LINE_STRIPS.length, 2 + 2 * 33); assert.equal(msgs[0].LINE_STRIPS[2 + 32], 2); assert.equal(msgs[0].LINE_STRIPS[2 + 33 + 32], 1);});
test('favs.toggle removes a group containing the stop', function () { var got; favs.toggle([{ name: { fr: 'A', nl: 'A' }, ids: ['1', '2'] }], '2', 'A', function () { throw new Error('no search on remove'); }, function (result, next) { got = { result: result, next: next }; }); assert.equal(got.result, 0); assert.deepEqual(got.next, []);});
test('favs.toggle adds the resolved group for the stop', function () { var hits = [ { id: '10', name: { fr: 'BOURSE', nl: 'BEURS' }, lines: 2 }, { id: '11', name: { fr: 'BOURSE', nl: 'BEURS' }, lines: 2 }, { id: '99', name: { fr: 'OTHER', nl: 'OTHER' }, lines: 1 } ]; var got; favs.toggle([], '11', 'BEURS', function (q, cb) { cb(null, hits); }, function (result, next) { got = { result: result, next: next }; }); assert.equal(got.result, 1); assert.deepEqual(got.next, [{ name: { fr: 'BOURSE', nl: 'BEURS' }, ids: ['10', '11'] }]);});
test('favs.toggle answers full at eight groups', function () { var eight = []; for (var i = 0; i < 8; i++) { eight.push({ name: { fr: 'S' + i, nl: 'S' + i }, ids: [String(i)] }); } var got; favs.toggle(eight, 'X', 'X', function (q, cb) { cb(null, []); }, function (result, next) { got = { result: result, next: next }; }); assert.equal(got.result, 2); assert.equal(got.next.length, 8);});
test('favs.toggle answers failed when no group matches', function () { var got; favs.toggle([], 'X', 'GHOST', function (q, cb) { cb(null, []); }, function (result) { got = result; }); assert.equal(got, 3); favs.toggle([], 'X', 'GHOST', function (q, cb) { cb(new Error('net')); }, function (result) { got = result; }); assert.equal(got, 3);});
test('favs.toggle merges intersecting groups into the resolved group', function () { var hits = [ { id: '10', name: { fr: 'BOURSE', nl: 'BEURS' }, lines: 2 }, { id: '11', name: { fr: 'BOURSE', nl: 'BEURS' }, lines: 2 } ]; var got; favs.toggle([{ name: { fr: 'BOURSE', nl: 'BEURS' }, ids: ['10'] }], '11', 'BEURS', function (q, cb) { cb(null, hits); }, function (result, next) { got = { result: result, next: next }; }); assert.equal(got.result, 1); assert.deepEqual(got.next, [{ name: { fr: 'BOURSE', nl: 'BEURS' }, ids: ['10', '11'] }]);});
test('favs.toggle falls back to an exact name match when no group has the id', function () { var hits = [ { id: '20', name: { fr: 'GARE CENTRALE', nl: 'CENTRAAL STATION' }, lines: 1 }, { id: '21', name: { fr: 'GARE CENTRALE', nl: 'CENTRAAL STATION' }, lines: 1 } ]; var got; favs.toggle([], 'X', 'CENTRAAL STATION', function (q, cb) { cb(null, hits); }, function (result, next) { got = { result: result, next: next }; }); assert.equal(got.result, 1); assert.deepEqual(got.next, [ { name: { fr: 'GARE CENTRALE', nl: 'CENTRAAL STATION' }, ids: ['20', '21'] } ]); favs.toggle([], 'X', 'ELSEWHERE', function (q, cb) { cb(null, hits); }, function (result, next) { got = { result: result, next: next }; }); assert.equal(got.result, 3); assert.deepEqual(got.next, []);});
test('buildUrl maps page 3 to the selected stops endpoint', function () { var url = api.buildUrl('https://x', null, 3, ['10', '11']); assert.equal(url, 'https://x/v1/stops/board?ids=10,11');});
test('settings restores and migrates favs', function () { global.localStorage = { getItem: function () { return JSON.stringify({ favs: [{ name: { fr: 'A', nl: 'A' }, id: '1' }] }); }, setItem: function () {} }; delete require.cache[require.resolve('../src/pkjs/settings.js')]; var settings = require('../src/pkjs/settings.js'); var cfg = settings.load(); assert.deepEqual(cfg.favs, [{ name: { fr: 'A', nl: 'A' }, ids: ['1'] }]); delete global.localStorage;});
function fakeWatch(stored) { var state = { handlers: {}, sent: [], xhrs: [] }; var realSetInterval = global.setInterval; global.Pebble = { addEventListener: function (name, fn) { state.handlers[name] = fn; }, sendAppMessage: function (dict, ack) { state.sent.push(dict); ack(); } }; global.localStorage = { getItem: function (key) { return key === 'settings' ? JSON.stringify(stored) : null; }, setItem: function (key, value) { if (key === 'settings') { stored = JSON.parse(value); } } }; global.XMLHttpRequest = function () { var xhr = this; this.open = function (method, url) { xhr.url = url; }; this.send = function () { state.xhrs.push(xhr); }; }; global.setInterval = function () {}; ['queue.js', 'settings.js', 'geo.js', 'index.js'].forEach(function (mod) { delete require.cache[require.resolve('../src/pkjs/' + mod)]; }); require('../src/pkjs/index.js'); state.restore = function () { global.setInterval = realSetInterval; delete global.Pebble; delete global.localStorage; delete global.XMLHttpRequest; delete require.cache[require.resolve('../src/pkjs/index.js')]; }; return state;}
test('a stale board response is discarded and re-polled with the new favorites', function () { var watch = fakeWatch({ favs: [ { name: { fr: 'A', nl: 'A' }, ids: ['1'] }, { name: { fr: 'B', nl: 'B' }, ids: ['2'] } ] }); watch.handlers.appmessage({ payload: { KIND: 10, REQ_PAGE: 3 } }); assert.equal(watch.xhrs.length, 1); assert.ok(watch.xhrs[0].url.indexOf('/v1/stops/board?ids=1,2') !== -1); watch.handlers.appmessage({ payload: { KIND: 12, REQ_STOP_ID: '1', STOP_NAME: 'A' } }); assert.equal(watch.xhrs.length, 1); watch.xhrs[0].status = 200; watch.xhrs[0].responseText = JSON.stringify({ stale: false, stops: [{ id: '1', name: 'A', distance_m: 0, rows: [] }] }); watch.xhrs[0].onload(); assert.deepEqual(watch.sent, [{ KIND: 8, FAV_RESULT: 0 }]); assert.equal(watch.xhrs.length, 2); assert.ok(watch.xhrs[1].url.indexOf('/v1/stops/board?ids=2') !== -1); watch.restore();});
test('removing the last favorite mid-fetch ends on the local empty board', function () { var watch = fakeWatch({ favs: [{ name: { fr: 'A', nl: 'A' }, ids: ['1'] }] }); watch.handlers.appmessage({ payload: { KIND: 10, REQ_PAGE: 3 } }); assert.equal(watch.xhrs.length, 1); watch.handlers.appmessage({ payload: { KIND: 12, REQ_STOP_ID: '1', STOP_NAME: 'A' } }); watch.xhrs[0].status = 200; watch.xhrs[0].responseText = JSON.stringify({ stale: false, stops: [{ id: '1', name: 'A', distance_m: 0, rows: [] }] }); watch.xhrs[0].onload(); assert.equal(watch.xhrs.length, 1); watch.sent.forEach(function (dict) { assert.equal(dict.STOP_ID, undefined); assert.equal(dict.BOARD_ROWS, undefined); }); assert.deepEqual(watch.sent[watch.sent.length - 1], { KIND: 3, PAGE: 3, ERROR: 4 }); watch.restore();});
test('a stale board error is swallowed and re-polled with the new favorites', function () { var watch = fakeWatch({ favs: [ { name: { fr: 'A', nl: 'A' }, ids: ['1'] }, { name: { fr: 'B', nl: 'B' }, ids: ['2'] } ] }); watch.handlers.appmessage({ payload: { KIND: 10, REQ_PAGE: 3 } }); watch.handlers.appmessage({ payload: { KIND: 12, REQ_STOP_ID: '1', STOP_NAME: 'A' } }); assert.equal(watch.xhrs.length, 1); assert.strictEqual(watch.xhrs[0].ontimeout, watch.xhrs[0].onerror); watch.xhrs[0].onerror(); watch.sent.forEach(function (dict) { assert.notEqual(dict.KIND, 4); }); assert.equal(watch.xhrs.length, 2); assert.ok(watch.xhrs[1].url.indexOf('/v1/stops/board?ids=2') !== -1); watch.restore();});
test('a config save during an in-flight favorites fetch discards the stale response', function () { var watch = fakeWatch({ favs: [ { name: { fr: 'A', nl: 'A' }, ids: ['1'] }, { name: { fr: 'B', nl: 'B' }, ids: ['2'] } ] }); watch.handlers.appmessage({ payload: { KIND: 10, REQ_PAGE: 3 } }); assert.equal(watch.xhrs.length, 1); watch.handlers.webviewclosed({ response: encodeURIComponent(JSON.stringify({ labelLang: 'en', destLang: 'fr', vibrate: true, favs: [{ name: { fr: 'B', nl: 'B' }, ids: ['2'] }] })) }); assert.equal(watch.xhrs.length, 1); watch.xhrs[0].status = 200; watch.xhrs[0].responseText = JSON.stringify({ stale: false, stops: [{ id: '1', name: 'A', distance_m: 0, rows: [] }] }); watch.xhrs[0].onload(); watch.sent.forEach(function (dict) { assert.equal(dict.STOP_ID, undefined); assert.equal(dict.BOARD_ROWS, undefined); }); assert.equal(watch.xhrs.length, 2); assert.ok(watch.xhrs[1].url.indexOf('/v1/stops/board?ids=2') !== -1); watch.restore();});
test('a geo fix while on the favorites page issues no poll', function () { var watch = fakeWatch({ favs: [{ name: { fr: 'A', nl: 'A' }, ids: ['1'] }] }); var realSetTimeout = global.setTimeout; var realNow = Date.now; global.setTimeout = function () {}; var geoFix; var realNavigator = Object.getOwnPropertyDescriptor(global, 'navigator'); Object.defineProperty(global, 'navigator', { configurable: true, value: { geolocation: { watchPosition: function (onFix) { geoFix = onFix; } } } }); watch.handlers.ready(); watch.handlers.appmessage({ payload: { KIND: 10, REQ_PAGE: 3 } }); assert.equal(watch.xhrs.length, 1); watch.xhrs[0].status = 200; watch.xhrs[0].responseText = JSON.stringify({ stale: false, stops: [] }); watch.xhrs[0].onload(); Date.now = function () { return realNow() + 60000; }; geoFix({ coords: { latitude: 50.85, longitude: 4.35 } }); assert.equal(watch.xhrs.length, 1); Date.now = realNow; global.setTimeout = realSetTimeout; if (realNavigator) { Object.defineProperty(global, 'navigator', realNavigator); } else { delete global.navigator; } watch.restore();});