diff --git a/ivaldi.json b/ivaldi.json index bcedb2f..b3a009a 100644 --- a/ivaldi.json +++ b/ivaldi.json @@ -27,6 +27,8 @@ "targets/email.ts", "targets/_functions.ts", "conf.ts", + "logic.ts", + "test.ts", "main.ts" ] } diff --git a/source/logic.ts b/source/logic.ts new file mode 100644 index 0000000..693811f --- /dev/null +++ b/source/logic.ts @@ -0,0 +1,300 @@ +/* +This file is part of »munin«. + +Copyright 2025 'Fenris Wolf' + +»munin« is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +»munin« is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with »munin«. If not, see . + */ + + +namespace _munin.logic +{ + + /** + * @todo Tests schreiben + */ + function shall_remind( + event : _munin.type_event, + reminder : type_reminder, + { + "pit": pit = lib_plankton.pit.now(), + "interval": interval = 1, + } : { + pit ?: lib_plankton.pit.type_pit; + interval ?: int; + } = { + } + ) : boolean + { + const window_from : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( + pit, + 0 + ); + const window_to : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( + pit, + interval + ); + const event_begin : lib_plankton.pit.type_pit = lib_plankton.pit.from_datetime( + event.begin + ); + const reminder_time : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( + event_begin, + (-reminder) + ); + return lib_plankton.pit.is_between( + reminder_time, + window_from, + window_to + ); + } + + + /** + */ + function frequency_anchor( + frequency : _munin.enum_frequency, + { + "pit": pit = lib_plankton.pit.now(), + } : { + pit ?: lib_plankton.pit.type_pit, + } = { + } + ) : lib_plankton.pit.type_pit + { + switch (frequency) + { + case _munin.enum_frequency.hourly: return lib_plankton.pit.trunc_hour(pit); + case _munin.enum_frequency.daily: return lib_plankton.pit.trunc_day(pit); + case _munin.enum_frequency.weekly: return lib_plankton.pit.trunc_week(pit); + case _munin.enum_frequency.monthly: return lib_plankton.pit.trunc_month(pit); + default: + { + throw (new Error("unhandled frequency: " + frequency)); + break; + } + } + } + + + /** + */ + export function reminder_due( + reminder : type_reminder_new, + { + "pit": pit = lib_plankton.pit.now(), + "interval": interval = 1, + } : { + pit ?: lib_plankton.pit.type_pit; + interval ?: int; + } = { + } + ) : boolean + { + const anchor : lib_plankton.pit.type_pit = frequency_anchor( + reminder.frequency, + {"pit": pit} + ); + // 0 ≤ ((p - a(p)) - o) < i + const x : float = ( + ( + ( + lib_plankton.pit.to_unix_timestamp(pit) + - + lib_plankton.pit.to_unix_timestamp(anchor) + ) + / + (60 * 60) + ) + - + reminder.offset + ); + return ((0 <= x) && (x < interval)); + } + + + /** + */ + export function reminder_event_in_window( + reminder : type_reminder_new, + event : _munin.type_event, + { + "pit": pit = lib_plankton.pit.now(), + "interval": interval = 1, + } : { + pit ?: lib_plankton.pit.type_pit; + interval ?: int; + } = { + } + ) : boolean + { + const anchor : lib_plankton.pit.type_pit = frequency_anchor( + reminder.frequency, + {"pit": pit} + ); + const window_from : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( + anchor, + reminder.from + ); + const window_to : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( + anchor, + reminder.to + ); + const event_begin : lib_plankton.pit.type_pit = lib_plankton.pit.from_datetime( + event.begin + ); + return lib_plankton.pit.is_between( + event_begin, + window_from, + window_to + ); + } + + + /** + */ + function shall_remind_new( + reminder : type_reminder_new, + event : _munin.type_event, + { + "pit": pit = lib_plankton.pit.now(), + "interval": interval = 1, + } : { + pit ?: lib_plankton.pit.type_pit; + interval ?: int; + } = { + } + ) : boolean + { + return ( + reminder_due( + reminder, + {"pit": pit, "interval": interval} + ) + && + reminder_event_in_window( + reminder, + event, + {"pit": pit, "interval": interval} + ) + ); + } + + + /** + */ + async function run_iteration( + conf : _munin.conf.type_conf, + sources : Array<_munin.type_source>, + targets : Array<_munin.type_target>, + { + "dry_run": dry_run = false, + } : { + dry_run ?: boolean; + } = { + } + ) : Promise + { + const now : lib_plankton.pit.type_pit = lib_plankton.pit.now(); + const events : Array<_munin.type_event> = ( + (await Promise.all(sources.map(source => source.fetch()))) + .reduce((x, y) => x.concat(y), []) + ); + for (const target of targets) { + for (const reminder_hours of target.reminders) { + for (const event of events) { + const remind : boolean = shall_remind( + event, + reminder_hours, + { + "pit": now, + "interval": conf.settings.interval, + } + ); + if (! remind) { + // do nothing + } + else { + lib_plankton.log._info( + "munin.remind.do", + { + "details": { + "event": event, + "target": target.show(), + } + } + ); + if (dry_run) { + // do nothing + } + else { + try { + await target.send(conf.labels, event); + } + catch (error) { + lib_plankton.log.error( + "munin.remind.error", + { + "details": { + "message": String(error), + } + } + ); + } + } + } + } + } + } + } + + + /** + */ + export async function run( + conf : _munin.conf.type_conf, + { + "single_run": single_run = false, + "dry_run": dry_run = false, + } : { + single_run ?: boolean; + dry_run ?: boolean; + } = { + } + ) : Promise + { + const sources : Array<_munin.type_source> = conf.sources.map( + source_raw => _munin.sources.factory(source_raw) + ); + const targets : Array<_munin.type_target> = conf.targets.map( + target_raw => _munin.targets.factory(target_raw) + ); + lib_plankton.log._info( + "munin.run.start", + { + "details": { + } + } + ); + if (single_run) { + await run_iteration(conf, sources, targets, {"dry_run": dry_run}); + } + else { + while (true) { + await run_iteration(conf, sources, targets, {"dry_run": dry_run}); + await lib_plankton.call.sleep(conf.settings.interval * 60 * 60); + } + } + } + +} diff --git a/source/main.ts b/source/main.ts index f100ac2..9fb52fa 100644 --- a/source/main.ts +++ b/source/main.ts @@ -20,146 +20,7 @@ along with »munin«. If not, see . namespace _munin { - - /** - */ - async function run_iteration( - conf : _munin.conf.type_conf, - sources : Array<_munin.type_source>, - targets : Array<_munin.type_target>, - { - "dry_run": dry_run = false, - } : { - dry_run ?: boolean; - } = { - } - ) : Promise - { - const now : lib_plankton.pit.type_pit = lib_plankton.pit.now(); - const events : Array<_munin.type_event> = ( - (await Promise.all(sources.map(source => source.fetch()))) - .reduce((x, y) => x.concat(y), []) - ); - for (const target of targets) { - const window_from : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( - now, - 0 - ); - const window_to : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( - now, - conf.settings.interval - ); - lib_plankton.log._info( - "munin.run.iteration", - { - "details": { - "target": target.show(), - "window_from": lib_plankton.pit.to_date_object(window_from).toISOString(), - "window_to": lib_plankton.pit.to_date_object(window_to).toISOString(), - } - } - ); - for (const reminder_hours of target.reminders) { - for (const event of events) { - const event_begin : lib_plankton.pit.type_pit = lib_plankton.pit.from_datetime( - event.begin - ); - const reminder_time : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour( - event_begin, - (-reminder_hours) - ); - lib_plankton.log._info( - "munin.run.check_dueness", - { - "details": { - "event_begin": lib_plankton.pit.to_date_object(event_begin).toISOString(), - "reminder_hours": reminder_hours, - "reminder_time": lib_plankton.pit.to_date_object(reminder_time).toISOString(), - } - } - ); - const remind : boolean = lib_plankton.pit.is_between( - reminder_time, - window_from, - window_to - ); - if (! remind) { - // do nothing - } - else { - lib_plankton.log._info( - "munin.remind.do", - { - "details": { - "event": event, - "target": target.show(), - } - } - ); - if (dry_run) { - // do nothing - } - else { - try { - await target.send(conf.labels, event); - } - catch (error) { - lib_plankton.log.error( - "munin.remind.error", - { - "details": { - "message": String(error), - } - } - ); - } - } - } - } - } - } - } - - - /** - */ - async function run( - conf : _munin.conf.type_conf, - { - "single_run": single_run = false, - "dry_run": dry_run = false, - } : { - single_run ?: boolean; - dry_run ?: boolean; - } = { - } - ) : Promise - { - const sources : Array<_munin.type_source> = conf.sources.map( - source_raw => _munin.sources.factory(source_raw) - ); - const targets : Array<_munin.type_target> = conf.targets.map( - target_raw => _munin.targets.factory(target_raw) - ); - lib_plankton.log._info( - "munin.run.start", - { - "details": { - } - } - ); - if (single_run) { - await run_iteration(conf, sources, targets, {"dry_run": dry_run}); - } - else { - while (true) { - await run_iteration(conf, sources, targets, {"dry_run": dry_run}); - await lib_plankton.call.sleep(conf.settings.interval * 60 * 60); - } - } - } - - + /** */ export async function main( @@ -169,16 +30,14 @@ namespace _munin // args const arg_handler : lib_plankton.args.class_handler = new lib_plankton.args.class_handler( { - /* "action": lib_plankton.args.class_argument.positional({ "index": 0, "type": lib_plankton.args.enum_type.string, "mode": lib_plankton.args.enum_mode.replace, "default": "run", - "info": "what to do : help | run", + "info": "what to do : test | run", "name": "action", }), - */ "conf_path": lib_plankton.args.class_argument.volatile({ "indicators_long": ["conf-path"], "indicators_short": ["c"], @@ -274,67 +133,76 @@ namespace _munin ); } else { - // init - const conf : _munin.conf.type_conf = await _munin.conf.load(args.conf_path); - lib_plankton.log.set_main_logger( - [ - { - "kind": "filtered", - "data": { - "core": { - "kind": "std", - "data": { - "target": "stdout", - "format": { - "kind": "human_readable", - "data": { - } - } - } - }, - "predicate": [ - [ - { - "item": { - "kind": "level", - "data": { - "threshold": args.verbosity, - } - }, - }, - ] - ], - } - }, - ] - ); - - // exec - if (args.conf_expose) { - process.stdout.write( - lib_plankton.json.encode( - conf, - { - "formatted": true, - } - ) - + - "\n" - ); - } - switch (/*args.action*/"run") { - default: { + switch (args.action) { + default: + { throw (new Error("unhandled action: " + args.action)); break; } - case "run": { - run( + case "test": + { + _munin.test.all(); + break; + } + case "run": + { + // init + const conf : _munin.conf.type_conf = await _munin.conf.load(args.conf_path); + lib_plankton.log.set_main_logger( + [ + { + "kind": "filtered", + "data": { + "core": { + "kind": "std", + "data": { + "target": "stdout", + "format": { + "kind": "human_readable", + "data": { + } + } + } + }, + "predicate": [ + [ + { + "item": { + "kind": "level", + "data": { + "threshold": args.verbosity, + } + }, + }, + ] + ], + } + }, + ] + ); + + // exec + if (args.conf_expose) { + process.stdout.write( + lib_plankton.json.encode( + conf, + { + "formatted": true, + } + ) + + + "\n" + ); + } + + _munin.logic.run( conf, { "single_run": args.single_run, "dry_run": args.dry_run, } ); + break; } } diff --git a/source/test.ts b/source/test.ts new file mode 100644 index 0000000..91b0842 --- /dev/null +++ b/source/test.ts @@ -0,0 +1,384 @@ +/* +This file is part of »munin«. + +Copyright 2025 'Fenris Wolf' + +»munin« is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +»munin« is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with »munin«. If not, see . + */ + + +namespace _munin.test +{ + + /** + */ + type type_datetime = { + year : int; + month : int; + day : int; + hour : int; + minute : int; + second : int; + }; + + + /** + */ + function datetime_to_pit( + datetime : type_datetime + ) : lib_plankton.pit.type_pit + { + return lib_plankton.pit.from_datetime( + { + "date": { + "year": datetime.year, + "month": datetime.month, + "day": datetime.day, + }, + "time": { + "hour": datetime.hour, + "minute": datetime.minute, + "second": datetime.second, + }, + "timezone_shift": 0, + } + ); + } + + + /** + */ + function start( + name : string + ) : void + { + lib_plankton.log._info( + "test.run", + { + "details": { + "name": name, + } + } + ); + } + + + /** + */ + function fail( + name : string + ) : void + { + lib_plankton.log._error( + "test.failed", + { + "details": { + "name": name, + } + } + ); + } + + + /** + */ + function frequency_decode( + frequency_encoded : string + ) : _munin.enum_frequency + { + switch (frequency_encoded) + { + case "hourly": return _munin.enum_frequency.hourly; + case "daily": return _munin.enum_frequency.daily; + case "weekly": return _munin.enum_frequency.weekly; + case "monthly": return _munin.enum_frequency.monthly; + default: {throw new Error("unhandled");} + } + } + + + /** + */ + export function reminder_due( + ) : void + { + type type_testcase = { + name : string; + input : { + reminder : { + frequency : string; + offset : int; + from : int; + to : int; + }; + datetime : type_datetime; + interval : int; + }; + output : boolean; + }; + const testcases : Array = [ + { + "name": "reminder_due.test1", + "input": { + "reminder": { + "frequency": "daily", + "offset": 16, + "from": 24, + "to": 48 + }, + "datetime": { + "year": 2025, + "month": 6, + "day": 27, + "hour": 15, + "minute": 30, + "second": 0 + }, + "interval": 1 + }, + "output": false + }, + { + "name": "reminder_due.test2", + "input": { + "reminder": { + "frequency": "daily", + "offset": 16, + "from": 24, + "to": 48 + }, + "datetime": { + "year": 2025, + "month": 6, + "day": 27, + "hour": 16, + "minute": 30, + "second": 0, + }, + "interval": 1 + }, + "output": true + }, + { + "name": "reminder_due.test3", + "input": { + "reminder": { + "frequency": "daily", + "offset": 16, + "from": 24, + "to": 48 + }, + "datetime": { + "year": 2025, + "month": 6, + "day": 27, + "hour": 17, + "minute": 30, + "second": 0 + }, + "interval": 1 + }, + "output": false + }, + ]; + for (const testcase of testcases) + { + start(testcase.name); + + // execution + const result : boolean = _munin.logic.reminder_due( + { + "frequency": frequency_decode(testcase.input.reminder.frequency), + "offset": testcase.input.reminder.offset, + "from": testcase.input.reminder.from, + "to": testcase.input.reminder.to, + }, + { + "pit": datetime_to_pit(testcase.input.datetime), + "interval": testcase.input.interval, + } + ); + + // assertions + if (result !== testcase.output) + { + fail(testcase.name); + } + } + } + + + /** + */ + export function reminder_event_in_window( + ) : void + { + type type_testcase = { + name : string; + input : { + reminder : { + frequency : string; + offset : int; + from : int; + to : int; + }; + event : { + begin : type_datetime; + }; + datetime : type_datetime; + interval : int; + }; + output : boolean; + }; + const testcases : Array = [ + { + "name": "reminder_event_in_window.test1", + "input": { + "reminder": { + "frequency": "daily", + "offset": 0, + "from": 24, + "to": 48 + }, + "event": { + "begin": { + "year": 2025, + "month": 6, + "day": 25, + "hour": 12, + "minute": 0, + "second": 0 + } + }, + "datetime": { + "year": 2025, + "month": 6, + "day": 23, + "hour": 10, + "minute": 0, + "second": 0 + }, + "interval": 1 + }, + "output": false + }, + { + "name": "reminder_event_in_window.test2", + "input": { + "reminder": { + "frequency": "daily", + "offset": 0, + "from": 24, + "to": 48 + }, + "event": { + "begin": { + "year": 2025, + "month": 6, + "day": 25, + "hour": 12, + "minute": 0, + "second": 0 + } + }, + "datetime": { + "year": 2025, + "month": 6, + "day": 24, + "hour": 10, + "minute": 0, + "second": 0 + }, + "interval": 1 + }, + "output": true + }, + { + "name": "reminder_event_in_window.test3", + "input": { + "reminder": { + "frequency": "daily", + "offset": 0, + "from": 24, + "to": 48 + }, + "event": { + "begin": { + "year": 2025, + "month": 6, + "day": 25, + "hour": 12, + "minute": 0, + "second": 0 + } + }, + "datetime": { + "year": 2025, + "month": 6, + "day": 25, + "hour": 10, + "minute": 0, + "second": 0 + }, + "interval": 1 + }, + "output": false + }, + ]; + + for (const testcase of testcases) + { + start(testcase.name); + + // execution + const result : boolean = _munin.logic.reminder_event_in_window( + { + "frequency": frequency_decode(testcase.input.reminder.frequency), + "offset": testcase.input.reminder.offset, + "from": testcase.input.reminder.from, + "to": testcase.input.reminder.to, + }, + { + "title": "test", + "begin": lib_plankton.pit.to_datetime(datetime_to_pit(testcase.input.event.begin)), + "end": null, + "description": null, + "location": null, + "tags": null, + }, + { + "pit": datetime_to_pit(testcase.input.datetime), + "interval": testcase.input.interval, + } + ); + + // assertions + if (result !== testcase.output) + { + fail(testcase.name); + } + } + } + + + /** + */ + export function all( + ) : void + { + reminder_due(); + reminder_event_in_window(); + } + +} diff --git a/source/types.ts b/source/types.ts index fae9db8..e29db21 100644 --- a/source/types.ts +++ b/source/types.ts @@ -20,7 +20,17 @@ along with »munin«. If not, see . namespace _munin { - + + /** + */ + export enum enum_frequency { + hourly = "hourly", + daily = "daily", + weekly = "weekly", + monthly = "monthly", + } + + /** */ export type type_labels = { @@ -55,10 +65,28 @@ namespace _munin }; + /** + * @todo rename + * @todo extend + */ + export type type_reminder = int; + + + /** + * @todo rename + */ + export type type_reminder_new = { + frequency : enum_frequency; + offset : int; + from : int; + to : int; + }; + + /** */ export type type_target = { - reminders : Array; + reminders : Array; show : (() => string); send : ( (