diff --git a/.claude/agents/scriptable-reviewer.md b/.claude/agents/scriptable-reviewer.md new file mode 100644 index 0000000..d263e02 --- /dev/null +++ b/.claude/agents/scriptable-reviewer.md @@ -0,0 +1,35 @@ +--- +name: scriptable-reviewer +description: Reviews Scriptable widget scripts for size compatibility, API correctness, update-check pattern, and common iOS widget pitfalls +--- + +You are a Scriptable iOS widget expert. When invoked, review the provided script (or all recently edited scripts) for the following issues and report findings with line numbers and suggested fixes. + +## Review Checklist + +### Widget Size Compatibility +- Missing `config.widgetFamily` guard when layout differs between small/medium/large/accessory sizes +- Hardcoded pixel dimensions that will break at non-medium sizes +- Text that will overflow at small size + +### Scriptable API Correctness +- Use of unavailable APIs: no `fetch()`, `require()`, `fs`, `window`, `document`, or other browser/Node APIs +- `FileManager.iCloud()` vs `FileManager.local()` — iCloud is preferred for synced scripts; flag if using local without clear reason +- `Request` objects must call `.load()` or `.loadJSON()` — flag if response is accessed without awaiting load +- `Script.complete()` must be called at the end of every execution path + +### Update Check Pattern +- `updateCheck()` function should be present in scripts intended for public distribution +- Version constant should be a number, not a string + +### Settings Pattern +- Settings JSON should be read with null-safety (`fm.fileExists(settingsPath)` check before `readString`) +- Settings directory should be created if it doesn't exist before writing + +### Common Pitfalls +- `await` used outside async context +- Uncaught promise rejections (missing try/catch on network calls) +- Hardcoded API keys or tokens in the script body (flag as security issue) +- Missing `Script.setWidget(widget)` call when `config.runsInWidget` is true + +Report all findings grouped by severity: **Error** (will crash), **Warning** (may misbehave), **Info** (style/best practice). diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e723dbb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node tests/runner.js 2>&1 | tail -20" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'scriptable-mocks' && echo 'BLOCK: scriptable-mocks.js is shared by all tests — confirm this change is intentional before proceeding.' || true" + } + ] + } + ] + } +} diff --git a/.claude/skills/gen-test/SKILL.md b/.claude/skills/gen-test/SKILL.md new file mode 100644 index 0000000..fa01d45 --- /dev/null +++ b/.claude/skills/gen-test/SKILL.md @@ -0,0 +1,48 @@ +--- +name: gen-test +description: Generate a Node.js test file for a Scriptable widget script, following the project's existing test conventions in tests/ +disable-model-invocation: true +--- + +Generate a test file for the Scriptable script named by the user (e.g. `/gen-test Twitter Widget.js`). + +## Steps + +1. Read the target `.js` script from the project root +2. Read `tests/scriptable-mocks.js` to understand available mock factories +3. Identify pure/testable functions in the script (logic that doesn't require a live Scriptable runtime) +4. Create `tests/.test.js` following the exact pattern used in existing test files: + +```javascript +// Tests for .js +'use strict' + +const { + makeRequest, makeWebView, makeAlert, /* etc. — import only what's needed */ +} = require('./scriptable-mocks') + +// ─── Pure logic extracted from the script ──────────────────────────────────── + +// Copy or re-express pure functions from the script here so they can be tested +// without requiring the Scriptable runtime. + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +module.exports = async ({ test, describe, assert }) => { + describe('', () => { + test('example: returns expected value', () => { + // arrange + // act + // assert + }) + }) +} +``` + +5. Add the new test file to the `files` array in `tests/runner.js` so it runs automatically. + +## Rules +- Only test logic that can run in plain Node.js — no Scriptable runtime APIs in test bodies +- Use injected `assert` from Node's built-in `assert` module (strict mode) +- Follow the existing naming: `.test.js` +- Aim for at least 3 meaningful tests covering happy path, edge case, and error case diff --git a/.claude/skills/new-script/SKILL.md b/.claude/skills/new-script/SKILL.md new file mode 100644 index 0000000..8d12f6b --- /dev/null +++ b/.claude/skills/new-script/SKILL.md @@ -0,0 +1,65 @@ +--- +name: new-script +description: Scaffold a new Scriptable widget script with required headers, FileManager setup, settings JSON pattern, and updateCheck boilerplate +disable-model-invocation: true +--- + +Create a new Scriptable widget script based on the name and description provided by the user. + +The script MUST start with exactly these 3 lines (no blank line before them): +``` +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: ; icon-glyph: ; +``` + +Choose an appropriate `icon-color` from: red, deep-red, orange, yellow, green, teal, blue, deep-blue, indigo, purple, pink, white, gray, light-gray, dark-gray, black. +Choose an appropriate `icon-glyph` from Font Awesome glyph names (e.g. calendar-alt, cloud-sun, dove, chart-bar, bell, star). + +After the header, scaffold the following structure: + +```javascript +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: blue; icon-glyph: star; + +'use strict' + +const SCRIPT_VERSION = 1.0 + +let fm = FileManager.iCloud() +let scriptDir = fm.documentsDirectory() + '//' +let settingsPath = scriptDir + 'settings.json' + +if (!fm.fileExists(scriptDir)) fm.createDirectory(scriptDir, false) + +let needUpdated = await updateCheck(SCRIPT_VERSION) + +let settings = {} +if (fm.fileExists(settingsPath)) { + settings = JSON.parse(fm.readString(settingsPath)) +} + +// ─── Main widget ──────────────────────────────────────────────────────────── + +let widget = new ListWidget() +widget.backgroundColor = Color.dynamic(Color.white(), Color.black()) + +// TODO: build widget UI here + +if (config.runsInWidget) { + Script.setWidget(widget) +} else { + await widget.presentMedium() +} +Script.complete() + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function updateCheck(currentVersion) { + // Placeholder — replace with actual update URL for published scripts + return false +} +``` + +Save the file as `.js` in the project root. Ask the user for the script name, purpose, icon color, and icon glyph if not provided. diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 5e341b2..b7ca158 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -2,189 +2,284 @@ // These must be at the very top of the file. Do not edit. // icon-color: purple; icon-glyph: magic; /* -$$$$$$$$$$$$$$$$$$$$$$$ -$$$$$$$$$$ - Alexa To Reminders Access +Script made by: mvan231 — Date: 2023/10/26 -Script made by: mvan231 -Date made: 2023/10/26 +Syncs your Alexa shopping list to an iOS Reminders list. -Purpose: To sync alexa reminders to a iOS reminders list. previously IFTTT could do this, but Amazon revoked the Alexa IFTTT integration recently. +Setup: + 1. Set baseURL to your country's Amazon domain if not amazon.com + 2. Set reminderListName to match your Reminders list exactly + (will prompt to pick one if it doesn't match) + 3. Run once — a login WebView will appear. Sign in, then tap Done. -Setup: - - Insert the Amazon base url your country uses (if different from default) in the "baseURL" variable below - - Insert the name of the desired reminders list in the "reminderListName" line. I use "Grocery and Shopping" with my wife, so i have that name entered. - - Insert the wording for "Sign In" for the signInKeyvariable below. sometimes this varies based on region - - For proper naming preference, please use the withVar and withoutVar for your local language to properly set naming of the reminders to be created +Version history: + v9 — Rewrote to use loadURL+getHTML for all GET calls and a loadHTML + helper page for DELETE calls. No evaluateJavaScript for auth- + sensitive operations — avoids "unsupported type" errors entirely. + Amazon auth cookies (including HttpOnly) stay inside WKWebView + and are used automatically for all navigations within it. +*/ +// ─── Configuration ──────────────────────────────────────────────────────────── -When running the first time, the script will check if you are logged in. If not, it will notify and present with login page. After that, the script should run seamlessly. +const baseURL = 'https://www.amazon.com' +const signInKey = 'Sign in' +const withVar = 'with' +const withoutVar = 'without' -$$$$$$$$$$ -$$$$$$$$$$$$$$$$$$$$$$$ +let reminderListName = 'SHOPPING' -$$$$$$$$$$$$$$$$$$$$$$$ -$$$$$$$$$$ +// ─── Settings ───────────────────────────────────────────────────────────────── -Version Info: -v6 had to make update to accomodate slight change on Amazon's end -v5 used code from user andereeicheln0z frok github repo issue linked below to inprove performance -https://github.com/mvan231/Scriptable/issues/25 +const fm = FileManager.iCloud() +const settingsDir = fm.documentsDirectory() + '/AlexaToReminders/' +const settingsPath = settingsDir + 'settings.json' +if (!fm.fileExists(settingsDir)) fm.createDirectory(settingsDir, false) -v6 updated if statement from withVariable to withVar so it works properly +let settings = {} +if (fm.fileExists(settingsPath)) { + settings = JSON.parse(fm.readString(settingsPath)) + vlog(`Loaded settings: ${JSON.stringify(settings)}`) +} +if (settings.reminderListName) { + reminderListName = settings.reminderListName + vlog(`Using saved reminder list name: "${reminderListName}"`) +} -v7 updated to specifically target SHOPPING_LIST type and ignore other lists +// Single WebView — all Amazon requests run through this instance so +// auth cookies (including HttpOnly) are automatically included. +const sessionView = new WebView() -$$$$$$$$$$ -$$$$$$$$$$$$$$$$$$$$$$$ -*/ +await main() +Script.complete() -//set baseURL based on your home country url -const baseURL = 'https://www.amazon.com' +// ─── Logging ────────────────────────────────────────────────────────────────── -//include the reminder list name exactly as it is in Reminders app -const reminderListName = 'Grocery and Shopping' +function vlog(msg) { + const now = new Date() + const pad = n => String(n).padStart(2, '0') + const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + console.log(`[${ts}] ${msg}`) +} + +// ─── Amazon API ─────────────────────────────────────────────────────────────── + +async function fetchListJSON() { + const apiUrl = `${baseURL}/alexashoppinglists/api/getlistitems` + vlog(`Fetching list via XHR: ${apiUrl}`) -//signInKey should be specific for your language. English uses "Sign in". German uses "Anmelden" -const signInKey = "Sign in" + const helperPage = ` + +` -//withVar below needs to be set to your language's version of the word 'with' -const withVar = "with" + await sessionView.loadHTML(helperPage, baseURL) + const resultHtml = await sessionView.getHTML() + const match = resultHtml.match(/
]*>([\s\S]*?)<\/div>/i) + const raw = match ? match[1].trim() : '' + vlog(`XHR raw result (${raw.length} chars): ${raw.substring(0, 120)}`) + + if (!raw || raw === 'error' || raw.startsWith('err:')) { + vlog(`XHR failed: ${raw}`) + return null + } -//withoutVar below needs to be set to your language's version of the word 'without' -const withoutVar = "without" + const pipeIdx = raw.indexOf('|') + if (pipeIdx === -1) { vlog('Unexpected result format'); return null } -main(); -Script.complete(); + const status = parseInt(raw.substring(0, pipeIdx), 10) + const body = raw.substring(pipeIdx + 1) + vlog(`XHR status: ${status}, body (${body.length} chars): ${body.substring(0, 80)}`) + + if (status < 200 || status >= 300) { vlog(`Non-2xx status: ${status}`); return null } -async function checkIfUserIsAuthenticated() { try { - const url = `${baseURL}/alexashoppinglists/api/getlistitems`; - const request = new Request(url); - await request.load(); -log(request.response.statusCode) - if (request.response.statusCode === 401 || request.response.statusCode === 403) { - return false; + const json = JSON.parse(body) + if (typeof json === 'object' && json !== null && !Array.isArray(json)) { + vlog(`Valid JSON object with ${Object.keys(json).length} key(s)`) + return json } - - return true; - } catch (error) { - console.error(error); - return false; + vlog(`Non-object JSON (${typeof json}): ${String(json).substring(0, 80)}`) + return null + } catch (e) { + vlog(`JSON parse failed: ${e.message}`) + return null } } -async function makeLogin() { - const url = `${baseURL}`; - const webView = new WebView(); - - try { - await webView.loadURL(url) - const html = await webView.getHTML(); - if (html.includes(signInKey)) { - await webView.present(false); - return false; - } - - return true; - } catch (error) { - console.error(error); - return false; - } +// Send a DELETE via a loadHTML helper page so the request runs inside +// the WebView's authenticated session without needing evaluateJavaScript. +async function deleteListItem(item) { + const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem` + // Encode the body safely so it survives embedding in HTML + const encodedBody = encodeURIComponent(JSON.stringify(item)) + + const helperPage = ` + +` + + await sessionView.loadHTML(helperPage, baseURL) + const resultHtml = await sessionView.getHTML() + const match = resultHtml.match(/]*>([\s\S]*?)<\/body>/i) + const result = (match ? match[1] : resultHtml).replace(/<[^>]+>/g, '').trim() + vlog(`Delete response: ${result}`) + + if (result.startsWith('err:')) throw new Error(result) } -async function synchronizeReminders() { +// ─── Auth ───────────────────────────────────────────────────────────────────── + +async function ensureAuthenticated() { + // Try the API directly — if sessionView has a cached session it works immediately + vlog('Checking API access...') + let json = await fetchListJSON() + if (json) { vlog('Already authenticated'); return json } + + // Show instructions so the user knows to wait for Alexa lists to load + const alert = new Alert() + alert.title = 'Amazon Sign-In Required' + alert.message = 'Sign in to Amazon and wait for your Alexa Shopping List to load, then tap Done.' + alert.addAction('Open Amazon') + alert.addCancelAction('Cancel') + const choice = await alert.presentAlert() + if (choice === -1) { vlog('User cancelled sign-in'); return null } + + // Load the full Alexa web app — this establishes Alexa-specific session state + const alexaAppURL = baseURL.replace('://www.', '://alexa.') + vlog(`Loading Alexa web app: ${alexaAppURL}`) + await sessionView.loadURL(alexaAppURL) + const pageHtml = await sessionView.getHTML() + vlog(`Alexa app loaded (${pageHtml.length} chars) — sign-in indicator: ${pageHtml.includes(signInKey)}`) + await sessionView.present(false) + vlog('WebView dismissed — retrying API') + + json = await fetchListJSON() + if (json) { vlog('Authenticated after sign-in'); return json } + + vlog('Could not authenticate — API still not returning valid data') + return null +} + +// ─── Sync ───────────────────────────────────────────────────────────────────── + +async function synchronizeReminders(json) { + vlog(`Looking up reminder list: "${reminderListName}"`) try { - const reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); -log(reminderCalendar) - const url = `${baseURL}/alexashoppinglists/api/getlistitems`; - const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; - const json = await new Request(url).loadJSON() - log(json) - - // Find the list with listType === "SHOPPING_LIST" and ignore other lists - let listItems = []; - let shoppingListId = null; + let reminderCalendar = await Calendar.forRemindersByTitle(reminderListName) + if (!reminderCalendar) { + vlog(`"${reminderListName}" not found — fetching all lists`) + const allLists = await Calendar.forReminders() + if (!allLists || allLists.length === 0) { vlog('No reminder lists on device'); return } + vlog(`Available: ${allLists.map(l => l.title).join(', ')}`) + const alert = new Alert() + alert.title = 'Reminders List Not Found' + alert.message = `"${reminderListName}" doesn't exist. Pick a list:` + for (const list of allLists) alert.addAction(list.title) + alert.addCancelAction('Cancel') + const idx = await alert.presentSheet() + if (idx === -1) { vlog('Cancelled'); return } + reminderCalendar = allLists[idx] + vlog(`Selected: "${reminderCalendar.title}" — saving`) + settings.reminderListName = reminderCalendar.title + fm.writeString(settingsPath, JSON.stringify(settings)) + } else { + vlog(`Reminder list: "${reminderCalendar.title}"`) + } + vlog(`Scanning ${Object.keys(json).length} list(s) for SHOPPING_LIST...`) + let listItems = [], shoppingListId = null for (const listId in json) { - const list = json[listId]; - if (list.listInfo && list.listInfo.listType === "SHOPPING_LIST") { - listItems = list.listItems || []; - shoppingListId = listId; - log(`Found shopping list: ${list.listInfo.listName || 'Default Shopping List'} with ${listItems.length} items`); - break; + const list = json[listId] + const type = list.listInfo ? list.listInfo.listType : 'unknown' + vlog(` "${listId}" — ${type}`) + if (list.listInfo && list.listInfo.listType === 'SHOPPING_LIST') { + listItems = list.listItems || [] + shoppingListId = listId + vlog(`Found: "${list.listInfo.listName || 'Default'}" with ${listItems.length} item(s)`) + break } } - - // Exit if no shopping list found - if (!shoppingListId) { - log("No SHOPPING_LIST found in the response"); - return; - } - - // Exit if shopping list is empty - if (listItems.length === 0) { - log("Shopping list is empty, nothing to sync"); - return; - } - + + if (!shoppingListId) { vlog('No SHOPPING_LIST found — nothing to sync'); return } + if (listItems.length === 0) { vlog('Shopping list empty — nothing to sync'); return } + + vlog(`Loading existing reminders from "${reminderCalendar.title}"`) + const allReminders = await Reminder.all([reminderCalendar]) + const incompleteReminders = allReminders.filter(r => !r.isCompleted) + vlog(`${allReminders.length} total, ${incompleteReminders.length} incomplete`) + + let created = 0, skipped = 0, deleted = 0, deleteFailed = 0 + for (const item of listItems) { + if (!item.value) { vlog(`Skipping item with no value`); continue } + const reminderTitle = item.value.split(' ').map(word => { - if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) { - return word.toLowerCase(); - } else { - return word.charAt(0).toUpperCase() + word.slice(1); - } - }).join(' '); - const allReminders = await Reminder.all([reminderCalendar]); - const incompleteReminders = allReminders.filter(reminder => !reminder.isCompleted); - const reminderExists = incompleteReminders.some(reminder => reminder.title === reminderTitle); - - if (!reminderExists) { - const reminder = new Reminder(); - reminder.title = reminderTitle; - reminder.calendar = reminderCalendar; - await reminder.save(); + if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) return word.toLowerCase() + return word.charAt(0).toUpperCase() + word.slice(1) + }).join(' ') + if (incompleteReminders.some(r => r.title === reminderTitle)) { + vlog(`Skip (exists): "${reminderTitle}"`) + skipped++ + } else { + vlog(`Create: "${reminderTitle}"`) + const reminder = new Reminder() + reminder.title = reminderTitle + reminder.calendar = reminderCalendar + await reminder.save() + created++ } - const request = new Request(deleteUrl); - request.method = "DELETE"; - request.headers = { - "Content-Type": "application/json" - }; - request.body = JSON.stringify(item); - + vlog(`Delete from Alexa: "${item.value}"`) try { - const response = await request.loadString(); - log(`Deleted item from Alexa: ${item.value}`); - } catch (deleteError) { - console.error(`Failed to delete item: ${item.value}`); - console.error(deleteError); + await deleteListItem(item) + deleted++ + } catch (e) { + vlog(`Delete failed: ${e.message || e}`) + deleteFailed++ } } - - log(`Sync completed: processed ${listItems.length} items`); + + vlog(`Done — created: ${created}, skipped: ${skipped}, deleted: ${deleted}, failures: ${deleteFailed}`) } catch (error) { - log("Error during synchronization") - console.error(error); + vlog(`Sync error: ${error.message || error}`) + console.error(error) } } +// ─── Entry point ────────────────────────────────────────────────────────────── async function main() { - const isAuthenticated = await checkIfUserIsAuthenticated(); - log(`authenticated? ${isAuthenticated}`); - - if (!isAuthenticated) { - const loggedIn = await makeLogin(); - log(`loggedIn? ${loggedIn}`); - if (!loggedIn) { - console.log('Login failed. Exiting.'); - return; - } + vlog('=== Alexa To Reminders: starting ===') + const json = await ensureAuthenticated() + if (!json) { + vlog('Could not authenticate — exiting') + return } - - await synchronizeReminders(); + await synchronizeReminders(json) + vlog('=== Alexa To Reminders: done ===') } diff --git a/MEE6 LeaderBoard Info.js b/MEE6 LeaderBoard Info.js index 9eef1fd..359ce4d 100644 --- a/MEE6 LeaderBoard Info.js +++ b/MEE6 LeaderBoard Info.js @@ -39,7 +39,7 @@ let fm = FileManager.iCloud() log(config.runsInWidget) let b, urlBase = 'https://mee6.xyz/api/plugins/levels/leaderboard/' -dir=fm.documentsDirectory() +let dir = fm.documentsDirectory() let path = dir+"/MEE6.json" log(path) if(!fm.fileExists(path)){ @@ -105,7 +105,6 @@ if (uC.version != version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/MEE6%20LeaderBoard%20Info.js") @@ -125,11 +124,6 @@ End Update Check get data from current url item ---------------------*/ -let wv = new WebView() -await wv.loadURL(url) -wv.waitForLoad() -let html = await wv.getHTML() - /*--------------------- parse the results from the leaderboard to find your username ---------------------*/ @@ -140,10 +134,17 @@ json = await r.loadJSON() let str = json.players log(json) log(json.guild.name) -let rank,xp,level,count,id,avatar,progress,nextLevel -str.forEach(f) +let rank,xp,level,count,id,avatar,progress,nextLevel,userId +for (const [index, inp] of str.entries()) { + await f(inp, index) + if (rank !== undefined) break +} log(rank) +if (rank === undefined) { + throw new Error(`User "${b.uName}" was not found in the leaderboard. Check your username in the MEE6.json settings file.`) +} + /*--------------------- setup the widget ---------------------*/ @@ -179,9 +180,6 @@ log("file exists, loading now from iCloud Drive...") } pimg = fm.readImage(dir+'/'+avatar+'.png') -let addIm = w.addImage(pimg) -addIm.cornerRadius = 30 -addIm.centerAlignImage() rank=rank.toString() xp=xp.toString() level=level.toString() @@ -222,10 +220,11 @@ function fonter(text,size){ async function f(inp,index){ let h = JSON.stringify(inp) - if(regex.exec(h)) + const match = regex.exec(h) + if(match) { - log(regex.exec(h)) - let cc = JSON.parse(regex.exec(h)) + log(match) + let cc = JSON.parse(match[0]) log(cc) rank = index+1 xp = cc.xp diff --git a/README.md b/README.md index d6e775c..a4e01e7 100644 --- a/README.md +++ b/README.md @@ -163,18 +163,24 @@ * ### [Remove Scheduled Notifications.js](Remove%20Scheduled%20Notifications.js) * See how many pending notifications you have in the Scriptable app and choose from them to remove them, or alternatively, remove them all with one button. -* ### [Alexa to Reminders Access.js](Alexa%20To%20Reminders%20Access.js) +* ### [Alexa To Reminders Access.js](Alexa%20To%20Reminders%20Access.js) - * Purpose: To sync alexa reminders to a iOS reminders list. previously IFTTT could do this, but Amazon revoked the Alexa IFTTT integration recently. + * **Purpose:** Syncs your Alexa Shopping List to an iOS Reminders list. Previously IFTTT handled this, but Amazon revoked the Alexa IFTTT integration. Each item is created as a Reminder and then removed from the Alexa list so it doesn't sync again. - * Setup: - - Insert the Amazon base url your country uses (if different from default) in the "baseURL" variable below - - Insert the name of the desired reminders list in the "reminderListName" line. I use "Grocery and Shopping" with my wife, so i have that name entered. - - Insert the wording for "Sign In" for the signInKeyvariable below. sometimes this varies based on region - - For proper naming preference, please use the withVar and withoutVar for your local language to proeprly set naming of the reminders to be created + * **Setup:** + 1. Set `baseURL` to your country's Amazon domain if not `amazon.com` (e.g. `amazon.co.uk`) + 2. Set `reminderListName` to match your Reminders list name exactly (default: `SHOPPING`) + 3. Optionally adjust `withVar` / `withoutVar` for your language (used for title-casing item names) + 4. Run the script once — if not already signed in, an alert will appear. Tap **Open Amazon**, wait for your Alexa Shopping List to fully load in the WebView, then tap **Done** + * **How authentication works:** + - The script uses a single persistent WKWebView session. On the first run it loads `alexa.amazon.com` so you can sign in and let Amazon set all required session cookies. + - Once signed in, Amazon's cookies persist across runs — you won't be prompted again unless Amazon expires your session (e.g. after a password change or long inactivity). + - All API calls are made via XHR within the same WebView session, so auth cookies (including HttpOnly ones) are automatically included. - * When running the first time, the script will check if you are logged in. If not, it will notify and present with login page. After that, the script should run seamlessly. + * **Reminders list picker:** If the configured list name isn't found, the script will show a picker listing all available Reminders lists and save your choice for future runs. + + * **Settings** are stored in iCloud Drive → Scriptable → `AlexaToReminders/settings.json` and persist between runs. ## Utilites (from others) diff --git a/RH-Downloads.js b/RH-Downloads.js index ea55096..d193441 100644 --- a/RH-Downloads.js +++ b/RH-Downloads.js @@ -41,7 +41,6 @@ if (uC.version != version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/RH-Downloads.js") @@ -79,8 +78,8 @@ ff.dateFormat = 'd-MMM H:mm' let frmt = ff.string(now) log(frmt) -ab = FileManager.iCloud() -dir=ab.documentsDirectory() +let ab = FileManager.iCloud() +let dir = ab.documentsDirectory() let path = dir+"/RhLastUpd.json" log(path) let file @@ -91,7 +90,6 @@ if (!ab.fileExists(path)) file={} // file.date = now.getDate() file.index = -1 - ab.write(path, Data.fromString(JSON.stringify(file))) ab.writeString(path, JSON.stringify(file)) } //parse the existing file or the newly created file @@ -176,6 +174,9 @@ if file[name] does not exist, create it ##### */ +if (!name) { + throw new Error("Could not determine page name from URL. The site HTML may have changed.") +} if (!file[name]) { file[name] = { @@ -205,7 +206,7 @@ if (file[name].date != now.getDate()) } // calculate difference between new RH count, file download count and the previous delta value. this shows the change in downloads throughout the current day - diff = (str - file[name].downloads) + file[name].dayDelta + diff = (parseInt(str, 10) - parseInt(file[name].downloads, 10)) + file[name].dayDelta /* ##### @@ -252,7 +253,7 @@ ab.writeString(path, JSON.stringify(file)) sub.centerAlignText() // add downloads to widget - const titlew = w.addText(await str) + const titlew = w.addText(str) titlew.textColor = Color.blue() titlew.font = Font.boldSystemFont(13) titlew.centerAlignText() diff --git a/ScriptBackup.js b/ScriptBackup.js index 217878f..23f1dfb 100644 --- a/ScriptBackup.js +++ b/ScriptBackup.js @@ -32,18 +32,15 @@ const newDirName = `${dir}${backupTo}` ab.createDirectory(newDirName,true) -let a = ab.listContents(dir) - //provide a container for the script count let count = 0 -//for each item found in the directory, perform myFunction -a.forEach(myFunction) +backupDirectory(dir, newDirName) let aa = new Alert() aa.addAction("OK") aa.title = "Script Backup" aa.message = `All Done!\n${count} scripts backed up to\n${backupTo}` -aa.present() +await aa.present() //end of script Script.complete() @@ -51,12 +48,19 @@ Script.complete() Begin Functions */ -function myFunction(item, index){ - var ext = (ab.fileExtension(dir+"/"+item)) - if (ext == "js") - { - let file = ab.read(dir+"/"+item) - ab.write(newDirName+"/"+item, file) - count++ - } +function backupDirectory(sourceDir, targetDir) { + const items = ab.listContents(sourceDir) + items.forEach(item => { + const sourcePath = sourceDir + '/' + item + const ext = ab.fileExtension(sourcePath) + if (ext === 'js') { + const file = ab.read(sourcePath) + ab.write(targetDir + '/' + item, file) + count++ + } else if (ab.isDirectory(sourcePath) && item !== bDirName) { + const subTarget = targetDir + '/' + item + ab.createDirectory(subTarget, true) + backupDirectory(sourcePath, subTarget) + } + }) } \ No newline at end of file diff --git a/Twitter Widget.js b/Twitter Widget.js index 9c5ac73..28afb92 100644 --- a/Twitter Widget.js +++ b/Twitter Widget.js @@ -130,7 +130,7 @@ End Settings */ // run the updateCheck() function to see if there are any updates available for the script -let needUpdate = (checkUpdates="true")?await updateCheck(1.4):false +let needUpdate = (checkUpdates===true)?await updateCheck(1.4):false const twImgB64 = twit() let url,w = new ListWidget() if (args.widgetParameter){ @@ -202,8 +202,8 @@ Begin Functions $$$$$$$$$$$$$$$ */ async function apiCall(handle){ - let toke = Data.fromBase64String('QUFBQUFBQUFBQUFBQUFBQUFBQUFBTUdhS1FFQUFBQUExcDh0UmthNHJVdkF1a3VGRHJBNGxyU2dJNkklM0R5eHBsRnBoSFMxY2hOcTJXT1BzckNjWEFGRU5DbmtmMmZ6RE5reWtGdFBqZkpLSkFKRg==') - let bearer = toke.toRawString()/*old twitter bearer token -- no longer works*///'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA' + // Replace the empty string below with your own Twitter/X bearer token + let bearer = '' let firstUrl = 'https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name='+handle+'&count=50&exclude_replies=true&include_rts='+rtsOn let r = new Request(firstUrl) r.method='GET' @@ -340,7 +340,6 @@ async function updateCheck(version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/Twitter%20Widget.js") diff --git a/Upcoming Calendar Indicator.js b/Upcoming Calendar Indicator.js index 19694dc..477368e 100644 --- a/Upcoming Calendar Indicator.js +++ b/Upcoming Calendar Indicator.js @@ -1,15 +1,12 @@ // Variables used by Scriptable. // These must be at the very top of the file. Do not edit. // icon-color: light-gray; icon-glyph: calendar-alt; -// Variables used by Scriptable. -// These must be at the very top of the file. Do not edit. -// icon-color: light-gray; icon-glyph: calendar-alt; let fm = FileManager.iCloud() let scriptPath = fm.documentsDirectory()+'/UpcomingIndicator/' let settingsPath = scriptPath+'settings.json' const reRun = URLScheme.forRunningScript() if(!fm.fileExists(scriptPath))fm.createDirectory(scriptPath, false) -let needUpdated = await updateCheck(2.2) +let needUpdated = await updateCheck(2.8) //log(needUpdated) /*-------------------------- |------version notes------ @@ -92,7 +89,6 @@ const useBackgroundColor = settings.useBackgroundColor //backgroundColor below is setup as darkGray by default but can be changed to hex as well -if(settings.useBackgroundColor){const backgroundColor = new Color(settings.backgroundColor)} //shows the dates before and after the current month and let showDatesBeforeAfter = settings.showDatesBeforeAfter @@ -550,8 +546,8 @@ async function setup(full){ if (!('heatMapMax' in settings)){ let heatMax = new Alert() - heatMax.title = 'heatMapColor Setup' - heatMax.message = 'What color would you like to use for the heat map in the month view?' + heatMax.title = 'heatMapMax Setup' + heatMax.message = 'What is the maximum number of completed reminders per day to show as full heat map intensity?' heatMax.addAction('1') heatMax.addAction('2') heatMax.addAction('3') @@ -710,8 +706,8 @@ async function createWidget() { sun = 0 } //textColor="" - if(i==sat)textColor=saturdayColor - if(i==sun)textColor=sundayColor + if(i==sat && useSaturdayColor)textColor=saturdayColor + if(i==sun && useSundayColor)textColor=sundayColor addWidgetTextLine(dateStackUp, `${month[i][j]}`, { @@ -755,7 +751,7 @@ if(useBaseTextColor)tColor=Color.dynamic(new Color(baseTextColorLight), new Colo }else{ //start reminder list check - if (remList&&(!prevMonth&&!nextMonth)){ + if (heatMapEnabled && remList&&(!prevMonth&&!nextMonth)){ let list = await Calendar.forRemindersByTitle(remList) let rem = await Reminder.completedBetween(st, fn, [list]) let ratio = rem.length/heatMapMax @@ -1053,9 +1049,9 @@ log(item.identifier) } // log(item.startDate+'\n'+isCalEvent?item.endDate:'') // log(item) - if(cal.includes(item.calendar.title) || !isCalEvent) + if(!isCalEvent || cal.includes(item.calendar.title)) { - indexed+=1 + indexed+=1 if(!allowDynamicSpacing)eventCounter=null switch (eventCounter) { case 1: @@ -1111,7 +1107,8 @@ if(useBaseTextColor)when.textColor=Color.dynamic(new Color(baseTextColorLight), dF.dateFormat='EEE' let eee = dF.string(dd) let dt = eee+' '+ddd+' ' - let multipleAllDay = (item.isAllDay && (new Date(item.startDate).getDate() != new Date(item.endDate).getDate())) + const _s = new Date(item.startDate), _e = new Date(item.endDate) + let multipleAllDay = (item.isAllDay && (_s.getFullYear() !== _e.getFullYear() || _s.getMonth() !== _e.getMonth() || _s.getDate() !== _e.getDate())) if(multipleAllDay){ dF.dateFormat='EEE MMM d' @@ -1186,7 +1183,6 @@ async function updateCheck(version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/Upcoming%20Calendar%20Indicator/Upcoming%20Calendar%20Indicator.js") diff --git a/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js b/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js index 3e60fb5..d59bcf7 100644 --- a/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js +++ b/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js @@ -1,8 +1,5 @@ // Variables used by Scriptable. // These must be at the very top of the file. Do not edit. -// icon-color: green; icon-glyph: magic; -// Variables used by Scriptable. -// These must be at the very top of the file. Do not edit. // icon-color: deep-green; icon-glyph: calendar-alt; let fm = FileManager.iCloud() let scriptPath = fm.documentsDirectory()+'/UpcomingIndicator/' @@ -108,7 +105,6 @@ const useBackgroundColor = settings.useBackgroundColor //backgroundColor below is setup as darkGray by default but can be changed to hex as well -if(settings.useBackgroundColor){const backgroundColor = new Color(settings.backgroundColor)} //shows the dates before and after the current month and let showDatesBeforeAfter = settings.showDatesBeforeAfter @@ -575,8 +571,8 @@ async function setup(full){ if (!('heatMapMax' in settings)){ let heatMax = new Alert() - heatMax.title = 'heatMapColor Setup' - heatMax.message = 'What color would you like to use for the heat map in the month view?' + heatMax.title = 'heatMapMax Setup' + heatMax.message = 'What is the maximum number of completed reminders per day to show as full heat map intensity?' heatMax.addAction('1') heatMax.addAction('2') heatMax.addAction('3') @@ -732,8 +728,8 @@ async function createWidget() { sat = 6 sun = 0 } - if(i==sat)textColor=saturdayColor - if(i==sun)textColor=sundayColor + if(i==sat && useSaturdayColor)textColor=saturdayColor + if(i==sun && useSundayColor)textColor=sundayColor addWidgetTextLine(dateStackUp, `${month[i][j]}`, { color: (dayColor && j==0)?dayColor:'',//textColor, @@ -766,7 +762,7 @@ if(useBaseTextColor)tColor=Color.dynamic(new Color(baseTextColorLight), new Colo if(i == sat || i == sun)dateStack.backgroundColor = new Color(satSunHighlightColor,(4/10)) }else{ //start reminder list check - if (remList&&(!prevMonth&&!nextMonth)){ + if (heatMapEnabled && remList&&(!prevMonth&&!nextMonth)){ let list = await Calendar.forRemindersByTitle(remList) let rem = await Reminder.completedBetween(st, fn, [list]) let ratio = rem.length/heatMapMax @@ -950,7 +946,7 @@ async function successCallback(result) { isCalEvent=false } - if ((((new Date(item.startDate).getTime() > now.getTime()) && (hideCompletedReminders?(!isCalEvent?(item.isCompleted?false:true):true):true)) || (showCurrentAllDayEvents?((new Date(item.startDate).getDate()==now.getDate() || (new Date(item.startDate).getTime()now.getTime())) && item.isAllDay):false) || (persistIncompleteReminders?(!isCalEvent?(!item.isCompleted):false):false) ) && (cal.includes(item.calendar.title) || !isCalEvent) && !(ids.includes(item.identifier))) { + if ((((new Date(item.startDate).getTime() > now.getTime()) && (hideCompletedReminders?(!isCalEvent?(item.isCompleted?false:true):true):true)) || (showCurrentAllDayEvents?((new Date(item.startDate).getDate()==now.getDate() || (isCalEvent && new Date(item.startDate).getTime()now.getTime())) && item.isAllDay):false) || (persistIncompleteReminders?(!isCalEvent?(!item.isCompleted):false):false) ) && (!isCalEvent || cal.includes(item.calendar.title)) && !(ids.includes(item.identifier))) { ids.push(item.identifier) return true } @@ -971,14 +967,13 @@ async function successCallback(result) { }); newCalArray = newCalArray.slice(0, 5) - newCalArray.forEach((earlyE,index) => { - if((new Date(earlyE.startDate).getTime() < now.getTime()) && !('endDate' in earlyE) && !earlyE.isCompleted){ - f(earlyE) - newCalArray.splice(index, 1) - } - }) + const earlyEvents = newCalArray.filter(e => + new Date(e.startDate).getTime() < now.getTime() && !('endDate' in e) && !e.isCompleted + ) + const normalEvents = newCalArray.filter(e => !earlyEvents.includes(e)) + earlyEvents.forEach(f) log('starting normal events') - newCalArray.forEach(f) + normalEvents.forEach(f) } @@ -1027,9 +1022,9 @@ function f(item){ dateString=dateString.replace('T',' ') item.endDate = dF.date(dateString) } - if(cal.includes(item.calendar.title) || !isCalEvent) + if(!isCalEvent || cal.includes(item.calendar.title)) { - indexed+=1 + indexed+=1 if(!allowDynamicSpacing)eventCounter=null switch (eventCounter) { case 1: @@ -1096,7 +1091,8 @@ if(useBaseTextColor)when.textColor=Color.dynamic(new Color(baseTextColorLight), dF.dateFormat='EEE' let eee = dF.string(dd) let dt = eee+' '+ddd+' ' - let multipleAllDay = (item.isAllDay && (new Date(item.startDate).getDate() != new Date(item.endDate).getDate())) + const _s = new Date(item.startDate), _e = new Date(item.endDate) + let multipleAllDay = (item.isAllDay && (_s.getFullYear() !== _e.getFullYear() || _s.getMonth() !== _e.getMonth() || _s.getDate() !== _e.getDate())) if(multipleAllDay){ dF.dateFormat='EEE MMM d' diff --git a/Weather Overview/Weather Overview.js b/Weather Overview/Weather Overview.js index e924689..cfc81b3 100644 --- a/Weather Overview/Weather Overview.js +++ b/Weather Overview/Weather Overview.js @@ -139,7 +139,9 @@ try { logTime('Fetching Location Data', startTime); if(!locFound){ try{ - latLong = JSON.parse(await localFm.readString(cachePath+'/locCache.json')) + const locRaw = localFm.readString(cachePath+'/locCache.json') + if (!locRaw) throw new Error('No cached location data available') + latLong = JSON.parse(locRaw) if(config.runsInApp)log('using cached location') }catch(e2){ if(config.runsInApp)log(e2+" could not get location") @@ -221,6 +223,7 @@ try { } catch(e) { if(config.runsInApp)log("Offline mode") let raw = localFm.readString(cache); + if (!raw) throw new Error("No cached weather data available. Connect to the internet and run once to cache data.") weatherData = JSON.parse(raw); usingCachedData = true } @@ -279,6 +282,7 @@ if(showHumidity){ //end humidity legend +var amtLabel = 0 //amtLabel flag for usage in drawPrecipitation — must be declared regardless of showPrecipitation //start adding precipitation POP and amount legend if(showPrecipitation){ if(!percentageLinesDrawn){ @@ -290,7 +294,6 @@ if(showPrecipitation){ drawAmountLabels() //add label for percentage if(showLegend)drawTextC("precPrb", 16, ((config.widgetFamily == "small") ? contextSize : mediumWidgetWidth) - 220,showWindspeed?5:25,180,20,new Color('1fb2b7',0.9)) - var amtLabel = 0 //amtLabel flag for usage in the function called within the hourData loop that starts after this } //end adding precipitation POP and amount legend @@ -303,32 +306,32 @@ for (let i = 0; i <= hoursToShow; i++) { let hourData = (param=='daily')?weatherData.daily:weatherData.hourly; // start cloud cover let cloudCover = (param!='daily' && i==0)?weatherData.current.clouds : hourData[i].clouds - let cloudCoverNext = hourData[i+1].clouds let yPos = 220-(((220-60)/100) * cloudCover) - let yPosNext = 220-(((220-60)/100) * cloudCoverNext) if(i 0) ? (shouldRound(roundedGraph, temp) - min) / diff : 0; let nextDelta = (diff>0) ? (nextHourTemp - min) / diff : 0 temp = shouldRound(roundedTemp, temp) - if (i < hoursToShow) { - let hourDay = epochToDate(hourData[i].dt); - for (let i2 = 0 ; i2 < weatherData.daily.length; i2++) { - let day = weatherData.daily[i2]; - if (isSameDay(epochToDate(day.dt), epochToDate(hourData[i].dt))) { - hourDay = day; - break; - } - } - - //check if it is day / night - now = new Date() - var night = (hourData[i].dt > hourDay.sunset || hourData[i].dt < hourDay.sunrise || (i == 0 && (now.getTime > weatherData.current.sunset || now.getTime < weatherData.current.sunrise))) - var freezing = (units=='imperial'?32:0) - var tempColor = (temp>freezing)?Color.orange():Color.blue() - - if(param == "daily" && lowTemp){ - var lowTempColor = (lowTemp>freezing)?Color.orange():Color.blue() + // compute night and freezing for every iteration so the last column renders correctly + let hourDay = epochToDate(hourData[i].dt); + for (let i2 = 0 ; i2 < weatherData.daily.length; i2++) { + let day = weatherData.daily[i2]; + if (isSameDay(epochToDate(day.dt), epochToDate(hourData[i].dt))) { + hourDay = day; + break; } + } + now = new Date() + var night = (hourData[i].dt > hourDay.sunset || hourData[i].dt < hourDay.sunrise || (i == 0 && (now.getTime()/1000 > weatherData.current.sunset || now.getTime()/1000 < weatherData.current.sunrise))) + var freezing = (units=='imperial'?32:0) + var tempColor = (temp>freezing)?Color.orange():Color.blue() + if (i < hoursToShow) { drawLine(spaceBetweenDays * (i) + xStart + (barWidth/2)/*spaceBetweenDays * (i) + barWidth*/, 175 - (50 * delta),spaceBetweenDays * (i + 1) + xStart + (barWidth/2), 175 - (50 * nextDelta), 2,tempColor) //Color.gray())// (night ? Color.gray() : accentColor)) } @@ -423,6 +421,7 @@ for (let i = 0; i <= hoursToShow; i++) { widget.backgroundImage = (drawContext.getImage()) widget.url = cityId?`https://openweathermap.org/city/${cityId}` :'https://openweathermap.org' +Script.setWidget(widget) Script.complete() widget.presentMedium() @@ -485,7 +484,7 @@ function drawImage(image, x, y) { function drawPrecipitation(data, i) { if (i > hoursToShow)return; - let precipAmount = data.rain ? data.rain['1h'] * mmToInch : data.snow ? data.snow['1h'] * mmToInch : 0; + let precipAmount = data.rain ? (typeof data.rain === 'object' ? data.rain['1h'] : data.rain) * mmToInch : data.snow ? (typeof data.snow === 'object' ? data.snow['1h'] : data.snow) * mmToInch : 0; const pop = data.pop * 100; const barHeight = ((220 - 60) / 100) * pop; const precipBarHeight = ((220 - 60) / 100) * (100 * (precipAmount / maxPrecip)); diff --git a/tests/alexa-reminders.test.js b/tests/alexa-reminders.test.js new file mode 100644 index 0000000..7623d23 --- /dev/null +++ b/tests/alexa-reminders.test.js @@ -0,0 +1,592 @@ +// Tests for Alexa To Reminders Access.js +'use strict' + +const { + makeRequest, makeWebView, makeAlert, makeReminder, makeCalendar, makeFileManager, +} = require('./scriptable-mocks') + +// ─── Pure logic extracted from the script ──────────────────────────────────── + +function formatTitle(value, withVar, withoutVar) { + return value.split(' ').map(word => { + if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) { + return word.toLowerCase() + } + return word.charAt(0).toUpperCase() + word.slice(1) + }).join(' ') +} + +// ─── Testable versions of script functions (using injected deps) ────────────── + +function makeCheckIfUserIsAuthenticated(baseURL, requestFactory) { + return async function checkIfUserIsAuthenticated() { + try { + const url = `${baseURL}/alexashoppinglists/api/getlistitems` + const request = requestFactory(url) + await request.load() + const status = request.response.statusCode + if (status >= 200 && status < 300) return true + return false + } catch (error) { + return false + } + } +} + +function makeMakeLogin(baseURL, signInKey, checkIfUserIsAuthenticated, webViewFactory) { + return async function makeLogin() { + const url = `${baseURL}` + const webView = webViewFactory() + try { + await webView.loadURL(url) + const html = await webView.getHTML() + if (html.includes(signInKey)) { + await webView.present(false) + return await checkIfUserIsAuthenticated() + } + const apiAuthenticated = await checkIfUserIsAuthenticated() + if (!apiAuthenticated) { + await webView.present(false) + return await checkIfUserIsAuthenticated() + } + return true + } catch (error) { + return false + } + } +} + +function makeSynchronizeReminders(baseURL, reminderListName, withVar, withoutVar, calendarAPI, reminderAPI, requestFactory) { + return async function synchronizeReminders() { + const reminderCalendar = await calendarAPI.forRemindersByTitle(reminderListName) + const url = `${baseURL}/alexashoppinglists/api/getlistitems` + const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem` + const json = await requestFactory(url).loadJSON() + + let listItems = [] + let shoppingListId = null + + for (const listId in json) { + const list = json[listId] + if (list.listInfo && list.listInfo.listType === 'SHOPPING_LIST') { + listItems = list.listItems || [] + shoppingListId = listId + break + } + } + + if (!shoppingListId) return { status: 'no_shopping_list' } + if (listItems.length === 0) return { status: 'empty' } + + const processed = [] + const allReminders = await reminderAPI.all([reminderCalendar]) + for (const item of listItems) { + if (!item.value) continue + const reminderTitle = formatTitle(item.value, withVar, withoutVar) + const incompleteReminders = allReminders.filter(r => !r.isCompleted) + const reminderExists = incompleteReminders.some(r => r.title === reminderTitle) + + if (!reminderExists) { + const reminder = new makeReminder.cls() + reminder.title = reminderTitle + reminder.calendar = reminderCalendar + await reminder.save() + processed.push({ title: reminderTitle, created: true }) + } else { + processed.push({ title: reminderTitle, created: false }) + } + + const deleteReq = requestFactory(deleteUrl) + deleteReq.method = 'DELETE' + deleteReq.headers = { 'Content-Type': 'application/json' } + deleteReq.body = JSON.stringify(item) + try { + await deleteReq.loadString() + } catch (_) {} + } + return { status: 'ok', processed } + } +} + +module.exports = async function({ test, describe, assert }) { + + // ─── Title Formatting ────────────────────────────────────────────────────── + + await describe('formatTitle', async () => { + test('capitalizes the first letter of each word', () => { + assert.equal(formatTitle('milk eggs bread', 'with', 'without'), 'Milk Eggs Bread') + }) + + test('handles single word', () => { + assert.equal(formatTitle('apples', 'with', 'without'), 'Apples') + }) + + test('lowercases "with" regardless of casing', () => { + assert.equal(formatTitle('pasta WITH sauce', 'with', 'without'), 'Pasta with Sauce') + }) + + test('lowercases "without" regardless of casing', () => { + assert.equal(formatTitle('coffee WITHOUT sugar', 'with', 'without'), 'Coffee without Sugar') + }) + + test('handles already-capitalized words', () => { + assert.equal(formatTitle('ORANGE JUICE', 'with', 'without'), 'ORANGE JUICE') + }) + + test('preserves remaining characters after first letter', () => { + assert.equal(formatTitle('organic milk', 'with', 'without'), 'Organic Milk') + }) + + test('handles empty string gracefully', () => { + assert.equal(formatTitle('', 'with', 'without'), '') + }) + }) + + // ─── checkIfUserIsAuthenticated ──────────────────────────────────────────── + + await describe('checkIfUserIsAuthenticated', async () => { + test('returns true when server responds with 200', async () => { + const req = makeRequest({ response: { statusCode: 200 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), true) + }) + + test('returns false when server responds with 401', async () => { + const req = makeRequest({ response: { statusCode: 401 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('returns false when server responds with 403', async () => { + const req = makeRequest({ response: { statusCode: 403 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('returns false on network error', async () => { + const req = makeRequest({ + load: async () => { throw new Error('Network failure') }, + response: { statusCode: 0 }, + }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('builds the correct API URL', async () => { + let capturedUrl = null + const check = makeCheckIfUserIsAuthenticated('https://www.amazon.co.uk', (url) => { + capturedUrl = url + return makeRequest({ response: { statusCode: 200 } }) + }) + await check() + assert.equal(capturedUrl, 'https://www.amazon.co.uk/alexashoppinglists/api/getlistitems') + }) + + test('returns false when server responds with 500', async () => { + const req = makeRequest({ response: { statusCode: 500 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('returns false when server responds with 302 redirect', async () => { + const req = makeRequest({ response: { statusCode: 302 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + }) + + // ─── makeLogin ───────────────────────────────────────────────────────────── + + await describe('makeLogin', async () => { + test('returns true when sign-in indicator absent and API confirms authenticated', async () => { + const wv = makeWebView({ getHTML: async () => 'Welcome back, User!' }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), true) + }) + + test('presents fallback WebView when sign-in indicator absent but API returns 401', async () => { + let presented = false + const wv = makeWebView({ + getHTML: async () => 'Welcome back, User!', + present: async () => { presented = true }, + }) + let callCount = 0 + const check = async () => { callCount++; return false } + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + const result = await login() + assert.equal(presented, true) + assert.equal(result, false) + assert.equal(callCount, 2) // checked before and after fallback WebView + }) + + test('presents WebView when sign-in key is detected', async () => { + let presented = false + const wv = makeWebView({ + getHTML: async () => 'Please Sign in to continue', + present: async () => { presented = true }, + }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + await login() + assert.equal(presented, true) + }) + + test('returns result of checkIfUserIsAuthenticated after WebView login succeeds', async () => { + const wv = makeWebView({ getHTML: async () => 'Sign in here' }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), true) + }) + + test('returns false when checkIfUserIsAuthenticated fails after login attempt', async () => { + const wv = makeWebView({ getHTML: async () => 'Sign in here' }) + const check = async () => false + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), false) + }) + + test('returns false on WebView load error', async () => { + const wv = makeWebView({ loadURL: async () => { throw new Error('Cannot load') } }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), false) + }) + }) + + // ─── synchronizeReminders ────────────────────────────────────────────────── + + // Stub Reminder class for injection + makeReminder.cls = class { + constructor() { this.title = ''; this.calendar = null; this.isCompleted = false } + async save() { makeReminder.cls._saved.push(this) } + static _saved = [] + static reset() { makeReminder.cls._saved = [] } + } + + const baseConfig = { + baseURL: 'https://amazon.com', + reminderListName: 'Grocery and Shopping', + withVar: 'with', + withoutVar: 'without', + calendarAPI: { forRemindersByTitle: async () => makeCalendar() }, + reminderAPI: { all: async () => [] }, + } + + function buildSync(overrides = {}) { + const cfg = { ...baseConfig, ...overrides } + return makeSynchronizeReminders( + cfg.baseURL, cfg.reminderListName, cfg.withVar, cfg.withoutVar, + cfg.calendarAPI, cfg.reminderAPI, cfg.requestFactory || (() => makeRequest()) + ) + } + + await describe('synchronizeReminders', async () => { + test('returns "no_shopping_list" when no SHOPPING_LIST exists in response', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + requestFactory: () => makeRequest({ loadJSON: async () => ({ + list1: { listInfo: { listType: 'TODO' }, listItems: [] }, + }) }), + }) + const result = await sync() + assert.equal(result.status, 'no_shopping_list') + }) + + test('returns "empty" when SHOPPING_LIST has no items', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + requestFactory: () => makeRequest({ loadJSON: async () => ({ + list1: { listInfo: { listType: 'SHOPPING_LIST' }, listItems: [] }, + }) }), + }) + const result = await sync() + assert.equal(result.status, 'empty') + }) + + test('creates reminders for new items', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [ + { value: 'milk' }, + { value: 'eggs' }, + ], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.status, 'ok') + assert.equal(result.processed.filter(p => p.created).length, 2) + assert.equal(makeReminder.cls._saved.length, 2) + }) + + test('skips creating reminder when one already exists with same title', async () => { + makeReminder.cls.reset() + const existingReminder = { title: 'Milk', isCompleted: false } + const sync = buildSync({ + reminderAPI: { all: async () => [existingReminder] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'milk' }], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.processed[0].created, false) + assert.equal(makeReminder.cls._saved.length, 0) + }) + + test('completed reminders do not count as existing', async () => { + makeReminder.cls.reset() + const completedReminder = { title: 'Milk', isCompleted: true } + const sync = buildSync({ + reminderAPI: { all: async () => [completedReminder] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'milk' }], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.processed[0].created, true) + }) + + test('capitalizes reminder title correctly', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'orange juice with pulp' }], + }, + }), + loadString: async () => 'ok', + }), + }) + await sync() + assert.equal(makeReminder.cls._saved[0].title, 'Orange Juice with Pulp') + }) + + test('continues processing remaining items when delete fails for one', async () => { + makeReminder.cls.reset() + let deleteCallCount = 0 + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: (url) => { + if (url.includes('getlistitems')) { + return makeRequest({ loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'milk' }, { value: 'eggs' }], + }, + }) }) + } + return makeRequest({ loadString: async () => { + deleteCallCount++ + if (deleteCallCount === 1) throw new Error('Delete failed') + return 'ok' + } }) + }, + }) + const result = await sync() + assert.equal(result.status, 'ok') + assert.equal(result.processed.length, 2) + assert.equal(deleteCallCount, 2) // both deletes were attempted + }) + + test('skips items with null or missing value without crashing', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: null }, { value: 'milk' }], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.status, 'ok') + assert.equal(result.processed.length, 1) + assert.equal(result.processed[0].title, 'Milk') + }) + + test('finds SHOPPING_LIST correctly when multiple list types present', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + todoList: { listInfo: { listType: 'TODO' }, listItems: [{ value: 'call dentist' }] }, + shopList: { listInfo: { listType: 'SHOPPING_LIST' }, listItems: [{ value: 'butter' }] }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.processed.length, 1) + assert.equal(makeReminder.cls._saved[0].title, 'Butter') + }) + }) + + // ─── JSON response type validation ──────────────────────────────────────── + + await describe('JSON response type validation', async () => { + function isValidListResponse(json) { + return typeof json === 'object' && json !== null && !Array.isArray(json) + } + + test('accepts a plain object', () => { + assert.equal(isValidListResponse({ list1: {} }), true) + }) + + test('rejects a JSON string (Amazon session error message)', () => { + assert.equal(isValidListResponse("Not authenticated"), false) + }) + + test('rejects null', () => { + assert.equal(isValidListResponse(null), false) + }) + + test('rejects an array', () => { + assert.equal(isValidListResponse([1, 2, 3]), false) + }) + + test('rejects a number', () => { + assert.equal(isValidListResponse(42), false) + }) + }) + + // ─── settings persistence ───────────────────────────────────────────────── + + await describe('settings persistence', async () => { + test('saves selected reminderListName to settings file', () => { + const fm = makeFileManager() + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + const settings = {} + settings.reminderListName = 'Grocery' + fm.writeString(settingsPath, JSON.stringify(settings)) + const loaded = JSON.parse(fm.readString(settingsPath)) + assert.equal(loaded.reminderListName, 'Grocery') + }) + + test('loads reminderListName from settings when present', () => { + const fm = makeFileManager({ + '/mock/documents/AlexaToReminders/settings.json': JSON.stringify({ reminderListName: 'My List' }) + }) + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + let reminderListName = 'Shopping' + if (fm.fileExists(settingsPath)) { + const s = JSON.parse(fm.readString(settingsPath)) + if (s.reminderListName) reminderListName = s.reminderListName + } + assert.equal(reminderListName, 'My List') + }) + + test('falls back to default reminderListName when settings file absent', () => { + const fm = makeFileManager() + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + let reminderListName = 'Shopping' + if (fm.fileExists(settingsPath)) { + const s = JSON.parse(fm.readString(settingsPath)) + if (s.reminderListName) reminderListName = s.reminderListName + } + assert.equal(reminderListName, 'Shopping') + }) + + test('preserves existing settings keys when saving reminderListName', () => { + const fm = makeFileManager({ + '/mock/documents/AlexaToReminders/settings.json': JSON.stringify({ someOtherKey: true }) + }) + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + const settings = JSON.parse(fm.readString(settingsPath)) + settings.reminderListName = 'Groceries' + fm.writeString(settingsPath, JSON.stringify(settings)) + const loaded = JSON.parse(fm.readString(settingsPath)) + assert.equal(loaded.reminderListName, 'Groceries') + assert.equal(loaded.someOtherKey, true) + }) + }) + + // ─── main orchestration ──────────────────────────────────────────────────── + + await describe('main flow', async () => { + test('calls synchronize directly when already authenticated', async () => { + let syncCalled = false + const check = async () => true + const login = async () => true + const sync = async () => { syncCalled = true } + + async function main() { + const isAuthenticated = await check() + if (!isAuthenticated) { + const loggedIn = await login() + if (!loggedIn) return + } + await sync() + } + + await main() + assert.equal(syncCalled, true) + }) + + test('calls login then sync when not authenticated but login succeeds', async () => { + let loginCalled = false, syncCalled = false + const check = async () => false + const login = async () => { loginCalled = true; return true } + const sync = async () => { syncCalled = true } + + async function main() { + const isAuthenticated = await check() + if (!isAuthenticated) { + const loggedIn = await login() + if (!loggedIn) return + } + await sync() + } + + await main() + assert.equal(loginCalled, true) + assert.equal(syncCalled, true) + }) + + test('exits without syncing when not authenticated and login fails', async () => { + let syncCalled = false + const check = async () => false + const login = async () => false + const sync = async () => { syncCalled = true } + + async function main() { + const isAuthenticated = await check() + if (!isAuthenticated) { + const loggedIn = await login() + if (!loggedIn) return + } + await sync() + } + + await main() + assert.equal(syncCalled, false) + }) + }) +} diff --git a/tests/mee6.test.js b/tests/mee6.test.js new file mode 100644 index 0000000..c357b0c --- /dev/null +++ b/tests/mee6.test.js @@ -0,0 +1,171 @@ +// Tests for MEE6 LeaderBoard Info.js +'use strict' + +// ─── Player lookup logic extracted from the script ──────────────────────────── + +async function findPlayer(players, username) { + const regex = new RegExp('.*' + username + '.*') + let rank, xp, level, count, avatar, progress, nextLevel, userId + + for (const [index, inp] of players.entries()) { + const h = JSON.stringify(inp) + const match = regex.exec(h) + if (match) { + const cc = JSON.parse(match[0]) + rank = index + 1 + xp = cc.xp + level = cc.level + count = cc.message_count + avatar = cc.avatar + userId = cc.id + progress = cc.detailed_xp[0] + nextLevel = cc.detailed_xp[1] + break + } + } + + return { rank, xp, level, count, avatar, userId, progress, nextLevel } +} + +// ─── Guild index cycling logic ──────────────────────────────────────────────── + +function nextIndex(current, total) { + const next = current + 1 + return next >= total ? 0 : next +} + +// ─── Progress bar calculation ───────────────────────────────────────────────── + +function progressRatio(progress, nextLevel) { + if (!nextLevel || nextLevel === 0) return 0 + return Math.min(1, progress / nextLevel) +} + +module.exports = async function({ test, describe, assert }) { + + // ─── findPlayer ───────────────────────────────────────────────────────────── + + await describe('findPlayer', async () => { + const players = [ + { username: 'alice', id: '111', xp: 500, level: 3, message_count: 50, avatar: 'abc123', detailed_xp: [200, 400] }, + { username: 'bob', id: '222', xp: 300, level: 2, message_count: 30, avatar: 'def456', detailed_xp: [100, 300] }, + { username: 'carol', id: '333', xp: 100, level: 1, message_count: 10, avatar: 'ghi789', detailed_xp: [50, 200] }, + ] + + test('finds a player by username and returns correct rank', async () => { + const result = await findPlayer(players, 'bob') + assert.equal(result.rank, 2) + }) + + test('finds the first-place player', async () => { + const result = await findPlayer(players, 'alice') + assert.equal(result.rank, 1) + }) + + test('finds the last-place player', async () => { + const result = await findPlayer(players, 'carol') + assert.equal(result.rank, 3) + }) + + test('returns correct xp for found player', async () => { + const result = await findPlayer(players, 'bob') + assert.equal(result.xp, 300) + }) + + test('returns correct level for found player', async () => { + const result = await findPlayer(players, 'alice') + assert.equal(result.level, 3) + }) + + test('returns correct avatar hash for found player', async () => { + const result = await findPlayer(players, 'alice') + assert.equal(result.avatar, 'abc123') + }) + + test('returns correct userId for found player', async () => { + const result = await findPlayer(players, 'bob') + assert.equal(result.userId, '222') + }) + + test('returns correct progress (detailed_xp[0]) for found player', async () => { + const result = await findPlayer(players, 'carol') + assert.equal(result.progress, 50) + }) + + test('returns correct nextLevel (detailed_xp[1]) for found player', async () => { + const result = await findPlayer(players, 'carol') + assert.equal(result.nextLevel, 200) + }) + + test('returns undefined rank when player is not found', async () => { + const result = await findPlayer(players, 'nobody') + assert.equal(result.rank, undefined) + }) + + test('returns undefined userId when player is not found', async () => { + const result = await findPlayer(players, 'nobody') + assert.equal(result.userId, undefined) + }) + + test('handles an empty player list', async () => { + const result = await findPlayer([], 'alice') + assert.equal(result.rank, undefined) + }) + + test('matches partial username (regex is .*username.*)', async () => { + // "alic" should match player whose username contains "alic" + const result = await findPlayer(players, 'alic') + assert.equal(result.rank, 1) + }) + }) + + // ─── Guild index cycling ─────────────────────────────────────────────────── + + await describe('nextIndex (guild cycling)', async () => { + test('advances index by 1', () => { + assert.equal(nextIndex(0, 3), 1) + assert.equal(nextIndex(1, 3), 2) + }) + + test('wraps back to 0 when reaching the end', () => { + assert.equal(nextIndex(2, 3), 0) + }) + + test('single-item list always stays at 0', () => { + assert.equal(nextIndex(0, 1), 0) + }) + + test('wraps correctly for two-item list', () => { + assert.equal(nextIndex(0, 2), 1) + assert.equal(nextIndex(1, 2), 0) + }) + }) + + // ─── progressRatio ───────────────────────────────────────────────────────── + + await describe('progressRatio', async () => { + test('returns correct ratio for partial progress', () => { + assert.ok(Math.abs(progressRatio(200, 400) - 0.5) < 0.0001) + }) + + test('returns 1 when progress equals nextLevel', () => { + assert.equal(progressRatio(400, 400), 1) + }) + + test('caps at 1 when progress exceeds nextLevel', () => { + assert.equal(progressRatio(500, 400), 1) + }) + + test('returns 0 when progress is 0', () => { + assert.equal(progressRatio(0, 400), 0) + }) + + test('returns 0 when nextLevel is 0 (prevents division by zero)', () => { + assert.equal(progressRatio(100, 0), 0) + }) + + test('returns 0 when nextLevel is undefined', () => { + assert.equal(progressRatio(100, undefined), 0) + }) + }) +} diff --git a/tests/rh-downloads.test.js b/tests/rh-downloads.test.js new file mode 100644 index 0000000..78edfa2 --- /dev/null +++ b/tests/rh-downloads.test.js @@ -0,0 +1,179 @@ +// Tests for RH-Downloads.js +'use strict' + +// ─── Logic extracted from the script ───────────────────────────────────────── + +const NAME_FROM_URL_REGEX = /user\/(.+)(\/|)/ +const NAME_FROM_HTML_REGEX = /•\s(.+)<\/title>/ +const DOWNLOADS_REGEX = /

Downloads: (\d+)<\/p>/ + +function extractNameFromUrl(url) { + if (!url.includes('user')) return null + const match = NAME_FROM_URL_REGEX.exec(url) + return match ? match[1] : null +} + +function extractNameFromHtml(html) { + const match = NAME_FROM_HTML_REGEX.exec(html) + return match ? match[1] : null +} + +function extractDownloadCount(html) { + const match = DOWNLOADS_REGEX.exec(html) + return match ? match[1] : null +} + +function nextFileIndex(current, total) { + const next = current + 1 + return next >= total ? 0 : next +} + +function computeDailyDelta(currentStr, file, name, todayDate) { + const current = parseInt(currentStr, 10) + if (!file[name]) { + return { dayDelta: 0, downloads: currentStr, date: todayDate } + } + let dayDelta = file[name].dayDelta || 0 + if (file[name].date !== todayDate) { + dayDelta = 0 + } + const diff = (current - parseInt(file[name].downloads, 10)) + dayDelta + return { dayDelta: diff, downloads: currentStr, date: todayDate } +} + +module.exports = async function({ test, describe, assert }) { + + // ─── extractNameFromUrl ──────────────────────────────────────────────────── + + await describe('extractNameFromUrl', async () => { + test('extracts username from a user profile URL', () => { + assert.equal(extractNameFromUrl('https://routinehub.co/user/mvan231'), 'mvan231') + }) + + test('returns null for a non-user URL', () => { + assert.equal(extractNameFromUrl('https://routinehub.co/shortcut/5583/'), null) + }) + + test('handles trailing slash after username', () => { + const result = extractNameFromUrl('https://routinehub.co/user/mvan231/') + // regex captures "mvan231/" or "mvan231" depending on trailing slash group + assert.ok(result !== null) + assert.ok(result.startsWith('mvan231')) + }) + + test('returns null for empty string', () => { + assert.equal(extractNameFromUrl(''), null) + }) + }) + + // ─── extractNameFromHtml ────────────────────────────────────────────────── + + await describe('extractNameFromHtml', async () => { + test('extracts shortcut name from HTML title tag', () => { + const html = 'RoutineHub • My Cool Shortcut' + assert.equal(extractNameFromHtml(html), 'My Cool Shortcut') + }) + + test('returns null when pattern not found', () => { + const html = 'RoutineHub' + assert.equal(extractNameFromHtml(html), null) + }) + + test('handles names with spaces', () => { + const html = 'RoutineHub • Scriptable Widget Helper' + assert.equal(extractNameFromHtml(html), 'Scriptable Widget Helper') + }) + + test('returns null for empty HTML', () => { + assert.equal(extractNameFromHtml(''), null) + }) + }) + + // ─── extractDownloadCount ───────────────────────────────────────────────── + + await describe('extractDownloadCount', async () => { + test('extracts a numeric download count', () => { + const html = '

Downloads: 1234

' + assert.equal(extractDownloadCount(html), '1234') + }) + + test('extracts large download counts', () => { + const html = '

Downloads: 99999

' + assert.equal(extractDownloadCount(html), '99999') + }) + + test('returns null when pattern not found', () => { + const html = '

No download info here

' + assert.equal(extractDownloadCount(html), null) + }) + + test('returns null for empty HTML', () => { + assert.equal(extractDownloadCount(''), null) + }) + + test('does not match partial tags', () => { + const html = 'Downloads: 500' + assert.equal(extractDownloadCount(html), null) + }) + }) + + // ─── nextFileIndex ──────────────────────────────────────────────────────── + + await describe('nextFileIndex (URL cycling)', async () => { + test('advances from 0 to 1', () => { + assert.equal(nextFileIndex(0, 3), 1) + }) + + test('wraps from last to 0', () => { + assert.equal(nextFileIndex(2, 3), 0) + }) + + test('single URL always stays at 0', () => { + assert.equal(nextFileIndex(0, 1), 0) + }) + + test('advances through a two-URL list', () => { + assert.equal(nextFileIndex(0, 2), 1) + assert.equal(nextFileIndex(1, 2), 0) + }) + }) + + // ─── computeDailyDelta ──────────────────────────────────────────────────── + + await describe('computeDailyDelta', async () => { + test('returns 0 delta for a brand-new entry', () => { + const result = computeDailyDelta('100', {}, 'myShortcut', 15) + assert.equal(result.dayDelta, 0) + assert.equal(result.downloads, '100') + }) + + test('accumulates delta within the same day', () => { + const file = { myShortcut: { downloads: '90', dayDelta: 5, date: 15 } } + const result = computeDailyDelta('100', file, 'myShortcut', 15) + assert.equal(result.dayDelta, 15) // (100-90) + 5 + }) + + test('resets delta when day changes', () => { + const file = { myShortcut: { downloads: '90', dayDelta: 50, date: 14 } } + const result = computeDailyDelta('100', file, 'myShortcut', 15) + assert.equal(result.dayDelta, 10) // reset dayDelta to 0, then (100-90) + 0 + }) + + test('returns 0 delta when download count is unchanged', () => { + const file = { myShortcut: { downloads: '100', dayDelta: 0, date: 15 } } + const result = computeDailyDelta('100', file, 'myShortcut', 15) + assert.equal(result.dayDelta, 0) + }) + + test('updates stored downloads to the current count', () => { + const file = { myShortcut: { downloads: '90', dayDelta: 0, date: 15 } } + const result = computeDailyDelta('105', file, 'myShortcut', 15) + assert.equal(result.downloads, '105') + }) + + test('stores the current date in the result', () => { + const result = computeDailyDelta('100', {}, 'myShortcut', 15) + assert.equal(result.date, 15) + }) + }) +} diff --git a/tests/runner.js b/tests/runner.js new file mode 100644 index 0000000..bcd33f0 --- /dev/null +++ b/tests/runner.js @@ -0,0 +1,69 @@ +// Simple sequential test runner — no external dependencies required. +// Run with: node tests/runner.js +'use strict' +const { strict: assert } = require('assert') + +let passed = 0, failed = 0 +const failures = [] +let _queue = null // set by describe to collect tests sequentially + +async function runTest(name, fn) { + try { + const result = fn() + if (result && typeof result.then === 'function') await result + console.log(` ✓ ${name}`) + passed++ + } catch (e) { + console.log(` ✗ ${name}`) + console.log(` ${e.message}`) + failed++ + failures.push({ name, error: e }) + } +} + +// test() either queues (inside describe) or runs immediately +function test(name, fn) { + if (_queue) { _queue.push({ name, fn }); return } + return runTest(name, fn) +} + +// describe() collects all test() calls synchronously, then drains them sequentially +async function describe(suiteName, fn) { + console.log(`\n${suiteName}`) + const queue = [] + const prev = _queue + _queue = queue + const result = fn() + if (result && typeof result.then === 'function') await result + _queue = prev + for (const item of queue) await runTest(item.name, item.fn) +} + +async function runFile(path) { + const mod = require(path) + if (typeof mod === 'function') await mod({ test, describe, assert }) +} + +async function main() { + const files = [ + './alexa-reminders.test.js', + './weather-overview.test.js', + './mee6.test.js', + './rh-downloads.test.js', + './twitter-widget.test.js', + './upcoming-calendar.test.js', + ] + for (const f of files) await runFile(f) + + console.log(`\n${'─'.repeat(50)}`) + console.log(` ${passed} passed, ${failed} failed`) + if (failures.length) { + console.log('\nFailed tests:') + failures.forEach(({ name, error }) => { + console.log(` ✗ ${name}: ${error.message}`) + }) + process.exit(1) + } +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/tests/scriptable-mocks.js b/tests/scriptable-mocks.js new file mode 100644 index 0000000..0c04f50 --- /dev/null +++ b/tests/scriptable-mocks.js @@ -0,0 +1,188 @@ +// Mocks for all Scriptable iOS APIs used across the widget scripts. +'use strict' + +function makeRequest(overrides = {}) { + return { + _url: '', + method: 'GET', + headers: {}, + body: null, + response: { statusCode: 200 }, + load: async function() { return Buffer.from('') }, + loadJSON: async function() { return {} }, + loadString: async function() { return '' }, + loadImage: async function() { return makeImage() }, + ...overrides, + } +} + +function makeImage() { + return { _isMockImage: true } +} + +function makeWebView(overrides = {}) { + return { + _loadedURL: null, + _html: '', + loadURL: async function(url) { this._loadedURL = url }, + loadHTML: async function(html) { this._html = html }, + getHTML: async function() { return this._html }, + waitForLoad: async function() {}, + present: async function() {}, + evaluateJavaScript: async function(js) { return '' }, + ...overrides, + } +} + +function makeAlert(overrides = {}) { + return { + title: '', + message: '', + _actions: [], + _textFields: [], + addAction: function(title) { this._actions.push({ title, destructive: false }) }, + addDestructiveAction: function(title) { this._actions.push({ title, destructive: true }) }, + addTextField: function(placeholder, value = '') { this._textFields.push({ placeholder, value }) }, + textFieldValue: function(i) { return this._textFields[i]?.value ?? '' }, + present: async function() { return 0 }, + presentSheet: async function() { return 0 }, + presentAlert: async function() { return 0 }, + ...overrides, + } +} + +function makeReminder(overrides = {}) { + return { + title: '', + calendar: null, + isCompleted: false, + save: async function() {}, + ...overrides, + } +} + +function makeCalendar(title = 'Test List') { + return { title, _isCalendar: true } +} + +function makeFileManager(files = {}) { + const store = { ...files } + return { + documentsDirectory: () => '/mock/documents', + joinPath: (a, b) => `${a}/${b}`, + fileExists: (path) => path in store, + readString: (path) => store[path] ?? null, + writeString: (path, content) => { store[path] = content }, + read: (path) => store[path] ? Buffer.from(store[path]) : null, + write: (path, data) => { store[path] = data?.toString() ?? '' }, + readImage: (path) => store[path] ? makeImage() : null, + writeImage: (path, img) => { store[path] = img }, + listContents: (path) => Object.keys(store) + .filter(k => k.startsWith(path)) + .map(k => k.replace(path + '/', '').split('/')[0]) + .filter((v, i, a) => a.indexOf(v) === i), + createDirectory: (path, intermediate) => {}, + fileExtension: (path) => path.includes('.') ? path.split('.').pop() : '', + _store: store, + } +} + +function makeColor(hex, alpha = 1) { + return { hex, alpha, _isMockColor: true } +} + +function makeDrawContext() { + return { + size: null, + opaque: false, + respectScreenScale: false, + setFont: () => {}, + setTextColor: () => {}, + setFillColor: () => {}, + setStrokeColor: () => {}, + setLineWidth: () => {}, + setTextAlignedCenter: () => {}, + setTextAlignedLeft: () => {}, + setTextAlignedRight: () => {}, + drawText: () => {}, + drawTextInRect: () => {}, + drawImageAtPoint: () => {}, + drawImageInRect: () => {}, + fillRect: () => {}, + fillEllipse: () => {}, + addPath: () => {}, + strokePath: () => {}, + fillPath: () => {}, + getImage: () => makeImage(), + } +} + +function makeListWidget() { + const items = [] + return { + _items: items, + _padding: null, + backgroundColor: null, + backgroundImage: null, + refreshAfterDate: null, + url: null, + addText: (text) => ({ text, font: null, textColor: null, textOpacity: 1, centerAlignText: () => {}, rightAlignText: () => {} }), + addImage: (img) => ({ image: img, resizable: false, imageSize: null, tintColor: null, cornerRadius: 0, centerAlignImage: () => {}, imageOpacity: 1 }), + addStack: () => makeStack(), + addSpacer: (size) => {}, + addDate: (date) => ({ date, font: null, textColor: null, applyRelativeStyle: () => {} }), + setPadding: (...args) => {}, + presentSmall: async () => {}, + presentMedium: async () => {}, + presentLarge: async () => {}, + } +} + +function makeStack() { + return { + addText: (text) => ({ text, font: null, textColor: null, centerAlignText: () => {}, rightAlignText: () => {} }), + addImage: (img) => ({ image: img, resizable: false, imageSize: null, tintColor: null, cornerRadius: 0, centerAlignImage: () => {}, imageOpacity: 1 }), + addStack: () => makeStack(), + addSpacer: () => {}, + addDate: (date) => ({ date, applyRelativeStyle: () => {} }), + layoutHorizontally: () => {}, + layoutVertically: () => {}, + setPadding: () => {}, + size: null, + url: null, + backgroundColor: null, + } +} + +function makeScriptable() { + return { + setWidget: () => {}, + complete: () => {}, + name: () => 'MockScript', + } +} + +function makeConfig(overrides = {}) { + return { + runsInWidget: false, + runsInApp: true, + widgetFamily: 'medium', + ...overrides, + } +} + +module.exports = { + makeRequest, + makeWebView, + makeAlert, + makeReminder, + makeCalendar, + makeFileManager, + makeColor, + makeDrawContext, + makeListWidget, + makeStack, + makeScriptable, + makeConfig, + makeImage, +} diff --git a/tests/twitter-widget.test.js b/tests/twitter-widget.test.js new file mode 100644 index 0000000..20f680f --- /dev/null +++ b/tests/twitter-widget.test.js @@ -0,0 +1,127 @@ +// Tests for Twitter Widget.js +'use strict' + +// ─── dateDelt output logic (parsing-independent) ────────────────────────────── +// The original parses via Scriptable's DateFormatter (iOS-only). We test the +// time-delta arithmetic and output-format selection by injecting a pre-parsed +// Date object, bypassing the platform-specific parsing step. + +function formatTimeDelta(outDate) { + const now = new Date() + const delta = Math.round((now.getTime() - outDate.getTime()) / 1000) + const deltaH = Math.floor(delta / 3600) + const deltaM = Math.floor((delta % 3600) / 60) + const deltaS = delta % 60 + + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + let tOut = months[outDate.getMonth()] + outDate.getDate() + + if (now.getDate() === outDate.getDate() || deltaH < 24) { + let val = deltaH, unit = 'h' + if (val < 1) { val = deltaM; unit = 'm' } + if (val < 1) { val = deltaS; unit = 's' } + tOut = val + unit + } + return tOut +} + +// ─── Twitter date string transform (testable without DateFormatter) ──────────── + +function transformTwitterDateString(dateString) { + let dt = dateString + dt = dt.replace(/^.{3}/, dt.match(/.{4}$/)) + dt = dt.replace(/.{4}$/, '') + return dt +} + +// ─── updateCheck comparison logic ───────────────────────────────────────────── + +function shouldCheckForUpdates(checkUpdates) { + return checkUpdates === true +} + +module.exports = async function({ test, describe, assert }) { + + // ─── formatTimeDelta ────────────────────────────────────────────────────── + + await describe('formatTimeDelta (dateDelt arithmetic)', async () => { + test('returns seconds suffix for a date 30 seconds ago', () => { + const d = new Date(Date.now() - 30 * 1000) + const result = formatTimeDelta(d) + assert.ok(result.endsWith('s'), `expected "s" suffix, got "${result}"`) + }) + + test('returns minutes suffix for a date 5 minutes ago', () => { + const d = new Date(Date.now() - 5 * 60 * 1000) + const result = formatTimeDelta(d) + assert.ok(result.endsWith('m'), `expected "m" suffix, got "${result}"`) + }) + + test('returns hours suffix for a date 3 hours ago', () => { + const d = new Date(Date.now() - 3 * 3600 * 1000) + const result = formatTimeDelta(d) + assert.ok(result.endsWith('h'), `expected "h" suffix, got "${result}"`) + assert.ok(result.startsWith('3'), `expected "3h", got "${result}"`) + }) + + test('returns a MonthDay string for a date clearly in a different month', () => { + // 47 days ensures deltaH ≈ 1128 (well > 24) and getDate() can never repeat monthly + const d = new Date(Date.now() - 47 * 24 * 3600 * 1000) + const result = formatTimeDelta(d) + assert.ok(!result.match(/^\d+[hms]$/), `expected date string but got "${result}"`) + }) + + test('returns "0s" for a date right now', () => { + const d = new Date() + const result = formatTimeDelta(d) + assert.ok(result.endsWith('s'), `expected "s" suffix, got "${result}"`) + }) + }) + + // ─── transformTwitterDateString ─────────────────────────────────────────── + + await describe('transformTwitterDateString', async () => { + test('moves year from end to front', () => { + const result = transformTwitterDateString('Mon Nov 13 10:45:30 +0000 2023') + assert.ok(result.startsWith('2023'), `expected to start with "2023", got "${result}"`) + }) + + test('removes the trailing year after transformation', () => { + const result = transformTwitterDateString('Mon Nov 13 10:45:30 +0000 2023') + // The trailing "2023" should be gone (only one year at front) + assert.equal(result.indexOf('2023'), 0) + assert.equal(result.lastIndexOf('2023'), 0) + }) + + test('produces correct format for DateFormatter parsing', () => { + const result = transformTwitterDateString('Mon Nov 13 10:45:30 +0000 2023') + assert.equal(result.trim(), '2023 Nov 13 10:45:30 +0000') + }) + }) + + // ─── shouldCheckForUpdates ──────────────────────────────────────────────── + + await describe('shouldCheckForUpdates (checkUpdates===true fix)', async () => { + test('returns true when checkUpdates is boolean true', () => { + assert.equal(shouldCheckForUpdates(true), true) + }) + + test('returns false when checkUpdates is boolean false', () => { + assert.equal(shouldCheckForUpdates(false), false) + }) + + test('returns false when checkUpdates is the string "true" (old bug)', () => { + // Before the fix, checkUpdates="true" (assignment) made this always truthy. + // Now we use ===true so the string "true" must not trigger updates. + assert.equal(shouldCheckForUpdates('true'), false) + }) + + test('returns false when checkUpdates is undefined', () => { + assert.equal(shouldCheckForUpdates(undefined), false) + }) + + test('returns false when checkUpdates is 1 (truthy but not strictly true)', () => { + assert.equal(shouldCheckForUpdates(1), false) + }) + }) +} diff --git a/tests/upcoming-calendar.test.js b/tests/upcoming-calendar.test.js new file mode 100644 index 0000000..f165c48 --- /dev/null +++ b/tests/upcoming-calendar.test.js @@ -0,0 +1,243 @@ +// Tests for Upcoming Calendar Indicator.js +'use strict' + +// ─── Logic extracted from the script ───────────────────────────────────────── + +function saturdayIndex(monWeekStart) { + return monWeekStart ? 5 : 6 +} + +function sundayIndex(monWeekStart) { + return monWeekStart ? 6 : 0 +} + +// Safe color assignment — only assigns if the flag is enabled (fixed version) +function resolveWeekendTextColor(colIndex, sat, sun, useSaturdayColor, saturdayColor, useSundayColor, sundayColor) { + let textColor = undefined + if (colIndex === sat && useSaturdayColor) textColor = saturdayColor + if (colIndex === sun && useSundayColor) textColor = sundayColor + return textColor +} + +// HeatMap guard — only runs when heatMapEnabled is true (fixed version) +function shouldRunHeatMap(heatMapEnabled, remList, prevMonth, nextMonth) { + return heatMapEnabled && !!remList && !prevMonth && !nextMonth +} + +// Progress bar ratio (same logic as heatmap ratio) +function heatRatio(completedCount, heatMapMax) { + if (!heatMapMax) return 0 + const ratio = completedCount / heatMapMax + return ratio > 1 ? 1 : ratio +} + +// Date-to-calshow epoch conversion used for tappable dates +function calshowEpoch(targetDate, originDate) { + return (targetDate - originDate) / 1000 +} + +// hideCompletedReminders filtering — extracted from subdirectory version filter predicate +function shouldIncludeItem(isCalEvent, isCompleted, hideCompletedReminders) { + return hideCompletedReminders ? (!isCalEvent ? (isCompleted ? false : true) : true) : true +} + +// persistIncompleteReminders — extracted from subdirectory version filter predicate +function shouldPersistIncomplete(isCalEvent, isCompleted, persistIncompleteReminders) { + return persistIncompleteReminders ? (!isCalEvent ? (!isCompleted) : false) : false +} + +module.exports = async function({ test, describe, assert }) { + + // ─── Weekend column indices ──────────────────────────────────────────────── + + await describe('saturdayIndex', async () => { + test('returns 5 when week starts on Monday', () => { + assert.equal(saturdayIndex(true), 5) + }) + + test('returns 6 when week starts on Sunday', () => { + assert.equal(saturdayIndex(false), 6) + }) + }) + + await describe('sundayIndex', async () => { + test('returns 6 when week starts on Monday', () => { + assert.equal(sundayIndex(true), 6) + }) + + test('returns 0 when week starts on Sunday', () => { + assert.equal(sundayIndex(false), 0) + }) + }) + + // ─── resolveWeekendTextColor ────────────────────────────────────────────── + + await describe('resolveWeekendTextColor', async () => { + const sat = 6, sun = 0 + + test('returns saturdayColor when column is Saturday and useSaturdayColor is true', () => { + const result = resolveWeekendTextColor(sat, sat, sun, true, '#FF0000', false, undefined) + assert.equal(result, '#FF0000') + }) + + test('returns undefined when column is Saturday but useSaturdayColor is false', () => { + const result = resolveWeekendTextColor(sat, sat, sun, false, '#FF0000', false, undefined) + assert.equal(result, undefined) + }) + + test('returns sundayColor when column is Sunday and useSundayColor is true', () => { + const result = resolveWeekendTextColor(sun, sat, sun, false, undefined, true, '#0000FF') + assert.equal(result, '#0000FF') + }) + + test('returns undefined when column is Sunday but useSundayColor is false', () => { + const result = resolveWeekendTextColor(sun, sat, sun, false, undefined, false, '#0000FF') + assert.equal(result, undefined) + }) + + test('returns undefined for a weekday column', () => { + const result = resolveWeekendTextColor(3, sat, sun, true, '#FF0000', true, '#0000FF') + assert.equal(result, undefined) + }) + + test('does not propagate undefined saturdayColor when flag is false', () => { + // This was the original bug — when useSaturdayColor=false, saturdayColor is undefined, + // and the old code would assign textColor=undefined + const result = resolveWeekendTextColor(sat, sat, sun, false, undefined, false, undefined) + assert.equal(result, undefined) + }) + }) + + // ─── shouldRunHeatMap ───────────────────────────────────────────────────── + + await describe('shouldRunHeatMap', async () => { + test('returns true when all conditions are met', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', false, false), true) + }) + + test('returns false when heatMapEnabled is false', () => { + assert.equal(shouldRunHeatMap(false, 'MyList', false, false), false) + }) + + test('returns false when remList is empty/null', () => { + assert.equal(shouldRunHeatMap(true, '', false, false), false) + assert.equal(shouldRunHeatMap(true, null, false, false), false) + }) + + test('returns false when showing previous month days', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', true, false), false) + }) + + test('returns false when showing next month days', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', false, true), false) + }) + + test('returns false when both prev and next month flags are set', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', true, true), false) + }) + + test('returns false when heatMapEnabled is false even with a valid remList', () => { + // This was the original bug — heatMapMax/heatMapColor were undefined when + // heatMapEnabled=false, crashing new Color(undefined) + assert.equal(shouldRunHeatMap(false, 'ShoppingList', false, false), false) + }) + }) + + // ─── heatRatio ──────────────────────────────────────────────────────────── + + await describe('heatRatio', async () => { + test('returns correct ratio for partial completion', () => { + assert.ok(Math.abs(heatRatio(3, 10) - 0.3) < 0.0001) + }) + + test('returns 1 when completedCount equals heatMapMax', () => { + assert.equal(heatRatio(10, 10), 1) + }) + + test('caps at 1 when completedCount exceeds heatMapMax', () => { + assert.equal(heatRatio(15, 10), 1) + }) + + test('returns 0 when no completions', () => { + assert.equal(heatRatio(0, 10), 0) + }) + + test('returns 0 when heatMapMax is 0 (prevents division by zero)', () => { + assert.equal(heatRatio(5, 0), 0) + }) + + test('returns 0 when heatMapMax is undefined (prevents crash)', () => { + assert.equal(heatRatio(5, undefined), 0) + }) + }) + + // ─── shouldIncludeItem (hideCompletedReminders) ─────────────────────────── + + await describe('shouldIncludeItem (hideCompletedReminders)', async () => { + test('includes calendar events regardless of completion', () => { + assert.equal(shouldIncludeItem(true, true, true), true) + assert.equal(shouldIncludeItem(true, false, true), true) + }) + + test('excludes completed reminders when hideCompletedReminders is true', () => { + assert.equal(shouldIncludeItem(false, true, true), false) + }) + + test('includes incomplete reminders when hideCompletedReminders is true', () => { + assert.equal(shouldIncludeItem(false, false, true), true) + }) + + test('includes completed reminders when hideCompletedReminders is false', () => { + assert.equal(shouldIncludeItem(false, true, false), true) + }) + }) + + // ─── shouldPersistIncomplete (persistIncompleteReminders) ───────────────── + + await describe('shouldPersistIncomplete (persistIncompleteReminders)', async () => { + test('returns true for past-due incomplete reminder when persist is enabled', () => { + assert.equal(shouldPersistIncomplete(false, false, true), true) + }) + + test('returns false for completed reminder even when persist is enabled', () => { + assert.equal(shouldPersistIncomplete(false, true, true), false) + }) + + test('returns false for calendar events regardless of persist flag', () => { + assert.equal(shouldPersistIncomplete(true, false, true), false) + assert.equal(shouldPersistIncomplete(true, true, true), false) + }) + + test('returns false when persistIncompleteReminders is false', () => { + assert.equal(shouldPersistIncomplete(false, false, false), false) + }) + }) + + // ─── calshowEpoch ───────────────────────────────────────────────────────── + + await describe('calshowEpoch', async () => { + test('returns 0 when target equals origin', () => { + const d = new Date('2024-06-15T12:00:00') + assert.equal(calshowEpoch(d, d), 0) + }) + + test('returns positive value when target is after origin', () => { + const origin = new Date('2024-01-01') + const target = new Date('2024-01-02') + assert.equal(calshowEpoch(target, origin), 86400) // 1 day in seconds + }) + + test('returns negative value when target is before origin', () => { + const origin = new Date('2024-01-02') + const target = new Date('2024-01-01') + assert.equal(calshowEpoch(target, origin), -86400) + }) + + test('returns a float for sub-second differences', () => { + const origin = new Date('2024-06-15T00:00:00.000') + const target = new Date('2024-06-15T00:00:00.500') + // 500ms / 1000 = 0.5 — the script passes this directly to calshow: URL + assert.equal(calshowEpoch(target, origin), 0.5) + }) + }) +} diff --git a/tests/weather-overview.test.js b/tests/weather-overview.test.js new file mode 100644 index 0000000..f180207 --- /dev/null +++ b/tests/weather-overview.test.js @@ -0,0 +1,197 @@ +// Tests for Weather Overview pure utility functions +'use strict' + +// ─── Functions extracted verbatim from Weather Overview.js ─────────────────── + +function epochToDate(epoch) { + return new Date(epoch * 1000) +} + +function shouldRound(should, value) { + return (should) ? Math.round(value) : value +} + +function isSameDay(date1, date2) { + return ( + date1.getYear() === date2.getYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ) +} + +// Daily vs hourly precipitation key selection (fixed version) +function getPrecipAmount(data, mmToInch) { + const rainVal = data.rain ? (typeof data.rain === 'object' ? data.rain['1h'] : data.rain) : null + const snowVal = data.snow ? (typeof data.snow === 'object' ? data.snow['1h'] : data.snow) : null + return rainVal !== null ? rainVal * mmToInch : snowVal !== null ? snowVal * mmToInch : 0 +} + +// Night detection (fixed version — uses getTime() not getTime) +function isNight(dtEpoch, hourDay, currentSunrise, currentSunset, isFirstEntry) { + const now = new Date() + return ( + dtEpoch > hourDay.sunset || + dtEpoch < hourDay.sunrise || + (isFirstEntry && (now.getTime() / 1000 > currentSunset || now.getTime() / 1000 < currentSunrise)) + ) +} + +module.exports = async function({ test, describe, assert }) { + + // ─── epochToDate ─────────────────────────────────────────────────────────── + + await describe('epochToDate', async () => { + test('converts Unix epoch (seconds) to a Date object', () => { + const epoch = 1700000000 + const result = epochToDate(epoch) + assert.ok(result instanceof Date) + assert.equal(result.getTime(), epoch * 1000) + }) + + test('epoch 0 maps to January 1 1970', () => { + const result = epochToDate(0) + assert.equal(result.getUTCFullYear(), 1970) + assert.equal(result.getUTCMonth(), 0) + assert.equal(result.getUTCDate(), 1) + }) + + test('preserves sub-minute precision', () => { + const epoch = 1700000045 + assert.equal(epochToDate(epoch).getTime(), 1700000045000) + }) + }) + + // ─── shouldRound ─────────────────────────────────────────────────────────── + + await describe('shouldRound', async () => { + test('rounds when flag is true', () => { + assert.equal(shouldRound(true, 72.6), 73) + }) + + test('returns raw float when flag is false', () => { + assert.equal(shouldRound(false, 72.6), 72.6) + }) + + test('rounds down correctly', () => { + assert.equal(shouldRound(true, 72.4), 72) + }) + + test('passes through integers unchanged', () => { + assert.equal(shouldRound(true, 70), 70) + }) + + test('handles negative values', () => { + assert.equal(shouldRound(true, -3.7), -4) + }) + }) + + // ─── isSameDay ───────────────────────────────────────────────────────────── + + await describe('isSameDay', async () => { + test('returns true for two Date objects on the same day', () => { + const a = new Date('2024-06-15T08:00:00') + const b = new Date('2024-06-15T22:45:00') + assert.equal(isSameDay(a, b), true) + }) + + test('returns false for dates on different days', () => { + const a = new Date('2024-06-15T23:59:59') + const b = new Date('2024-06-16T00:00:00') + assert.equal(isSameDay(a, b), false) + }) + + test('returns false for same day in different months', () => { + const a = new Date('2024-05-15') + const b = new Date('2024-06-15') + assert.equal(isSameDay(a, b), false) + }) + + test('returns false for same month/day in different years', () => { + const a = new Date('2023-06-15') + const b = new Date('2024-06-15') + assert.equal(isSameDay(a, b), false) + }) + + test('returns true when same Date object is compared to itself', () => { + const a = new Date('2024-06-15') + assert.equal(isSameDay(a, a), true) + }) + }) + + // ─── getPrecipAmount (daily vs hourly key fix) ───────────────────────────── + + await describe('getPrecipAmount', async () => { + const mmToInch = 394 / 10000 + + test('returns 0 when no rain or snow', () => { + assert.equal(getPrecipAmount({}, 1), 0) + }) + + test('uses rain["1h"] key for hourly data (object form)', () => { + const data = { rain: { '1h': 2.5 } } + assert.equal(getPrecipAmount(data, 1), 2.5) + }) + + test('uses plain rain number for daily data', () => { + const data = { rain: 5.0 } + assert.equal(getPrecipAmount(data, 1), 5.0) + }) + + test('uses snow["1h"] key for hourly snow data', () => { + const data = { snow: { '1h': 1.0 } } + assert.equal(getPrecipAmount(data, 1), 1.0) + }) + + test('uses plain snow number for daily snow data', () => { + const data = { snow: 3.0 } + assert.equal(getPrecipAmount(data, 1), 3.0) + }) + + test('rain takes precedence over snow when both present', () => { + const data = { rain: 2.0, snow: 1.0 } + assert.equal(getPrecipAmount(data, 1), 2.0) + }) + + test('applies mmToInch conversion factor', () => { + const data = { rain: 10.0 } + const result = getPrecipAmount(data, mmToInch) + assert.ok(Math.abs(result - 10.0 * mmToInch) < 0.0001) + }) + + test('hourly rain["1h"] value of 0 returns 0 (not falls through to snow)', () => { + const data = { rain: { '1h': 0 }, snow: { '1h': 5.0 } } + // rain is present (object), so snowVal should not be used + // rain value is 0, result is 0 + assert.equal(getPrecipAmount(data, 1), 0) + }) + }) + + // ─── isNight (night detection fix) ──────────────────────────────────────── + + await describe('isNight', async () => { + const sunrise = 1700000000 + const sunset = 1700050000 + + test('returns true when dt is after sunset', () => { + assert.equal(isNight(sunset + 100, { sunrise, sunset }, sunrise, sunset, false), true) + }) + + test('returns true when dt is before sunrise', () => { + assert.equal(isNight(sunrise - 100, { sunrise, sunset }, sunrise, sunset, false), true) + }) + + test('returns false during the day', () => { + const midday = (sunrise + sunset) / 2 + assert.equal(isNight(midday, { sunrise, sunset }, sunrise, sunset, false), false) + }) + + test('first entry checks system clock against current sunset', () => { + // Use a sunrise far in the future and sunset far in the past so current time is "night" + const pastSunset = Math.floor(Date.now() / 1000) - 7200 // 2 hours ago + const pastSunrise = Math.floor(Date.now() / 1000) - 36000 // 10 hours ago + const midday = (pastSunrise + pastSunset) / 2 + const result = isNight(midday, { sunrise: pastSunrise, sunset: pastSunset }, pastSunrise, pastSunset, true) + assert.equal(result, true) + }) + }) +}