From 9d0fe3f09970fadc1f4c66ed32cc4aef7fa7f56b Mon Sep 17 00:00:00 2001 From: tobiichi3227 Date: Thu, 16 Jan 2025 23:57:57 +0800 Subject: [PATCH 1/2] Feat(fortune): ci add special event checker --- .github/workflows/check-events.yml | 35 +++ dev/check-events.py | 380 +++++++++++++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 .github/workflows/check-events.yml create mode 100644 dev/check-events.py diff --git a/.github/workflows/check-events.yml b/.github/workflows/check-events.yml new file mode 100644 index 0000000..880f280 --- /dev/null +++ b/.github/workflows/check-events.yml @@ -0,0 +1,35 @@ +name: Check special events + +on: + pull_request: + paths: + - 'fortune_generator/json/custom_special.json' + - 'fortune_generator/json/static_special.json' + - 'fortune_generator/json/cyclical_special.json' + + push: + paths: + - 'fortune_generator/json/custom_special.json' + - 'fortune_generator/json/static_special.json' + - 'fortune_generator/json/cyclical_special.json' + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - name: Check Custom Special Events + run: | + python3 dev/check-events.py fortune_generator/json/custom_special.json custom + + - name: Check Static Special Events + run: | + python3 dev/check-events.py fortune_generator/json/static_special.json static + + - name: Check Cyclical Special Events + run: | + python3 dev/check-events.py fortune_generator/json/cyclical_special.json cyclical diff --git a/dev/check-events.py b/dev/check-events.py new file mode 100644 index 0000000..36d931f --- /dev/null +++ b/dev/check-events.py @@ -0,0 +1,380 @@ +#!/bin/python3 + +import logging +import collections +import datetime +import argparse +import enum +import json + +class DateType(enum.Enum): + CUSTOM = "custom" + STATIC = "static" + CYCLICAL = "cyclical" + + def __str__(self): + return self.name.lower() + + def __repr__(self): + return str(self) + + @staticmethod + def argparse(s): + try: + return DateType[s.upper()] + except KeyError: + return s + + +args_parser = argparse.ArgumentParser(description="special events checker") +args_parser.add_argument("path", type=str, help="event json file path") +args_parser.add_argument( + "type", + type=DateType.argparse, + choices=[t for t in DateType], + help="event date type", +) + +args = args_parser.parse_args() + +special_events: dict[str, list[dict]] = {} + +try: + with open(args.path) as f: + special_events = json.loads(f.read()) +except json.JSONDecodeError: + print(f"`{args.path}` json syntax error.") + exit(-1) + +except FileNotFoundError: + print(f"`{args.path}` not found.") + print("Please contact developer to solve this problem.") + exit(-1) + +if not isinstance(special_events, dict): + print("`special_events` should be a dict") + exit(-1) + +if "special_events" not in special_events: + print(f"`special_events` not found in `{args.path}`.") + exit(-1) + +if not isinstance(special_events["special_events"], list): + print(f"`special_events` in `{args.path}` should be a list.") + exit(-1) + +MIN_STATUS_INDEX = 0 +MAX_STATUS_INDEX = 7 +DAYSPERMONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + +errors: dict[int, list[str]] = collections.defaultdict(list) + + +def is_leap_year(year: int) -> bool: + """Determines whether a given year is a leap year. + + Args: + year (int): The year to check. + + Returns: + bool: True if the year is a leap year, False otherwise. + """ + + if year % 400 == 0: + return True + if year % 100 == 0: + return False + if year % 4 == 0: + return True + + return False + + +def validate_number(event_idx: int, value, min: int, max: int, field_name: str) -> int | None: + """Validates whether a given value is an integer within a specified range. + + Args: + event_idx (int): The index of the event for associating validation errors. + value (Any): The value to validate. + min (int): The minimum acceptable value (inclusive). + max (int): The maximum acceptable value (inclusive). + field_name (str): The name of the field being validated, used in error messages. + + Returns: + int | None: The validated integer value if it is within the range, otherwise None. + + Raises: + ValueError: If `value` cannot be converted to an integer. + + Validation Rules: + - If `value` cannot be converted to an integer, an error is recorded and None is returned. + - If `value` is outside the range defined by `min` and `max`, an error is recorded and None is returned. + """ + + try: + value = int(value) + except ValueError: + errors[event_idx].append(f"`{field_name}` should be between {min} and {max}") + return None + + if value < min or value > max: + errors[event_idx].append(f"`{field_name}` should be between {min} and {max}") + return None + + return value + + +def require_field_check( + obj: dict, event_idx: int, fields: list[tuple[str, type]], required_field: str = "" +) -> bool: + """ + Validates the presence and types of required fields in a given object. + + Args: + obj (dict): The object (dictionary) to validate. + event_idx (int): The index of the event for associating validation errors. + fields (list[tuple[str, type]]): A list of tuples where each tuple contains a field name and its expected type. + required_field (str, optional): An optional prefix for error messages to indicate a higher-level required field. Defaults to "". + + Returns: + bool: True if all required fields are present and have the correct types, otherwise False. + + Validation Rules: + - If a required field is missing, an error message is recorded. + - If a field is present but its type does not match the expected type, an error message is recorded. + - The `required_field` parameter, if provided, is prepended to error messages for context. + """ + + error_found = False + for field_name, field_type in fields: + if field_name not in obj: + error_found = True + msg = "" + if required_field != "": + msg = f"`{required_field}` " + + msg += f"missing `{field_name}`." + errors[event_idx].append(msg) + + elif not isinstance(obj[field_name], field_type): + error_found = True + errors[event_idx].append(f"`{field_name}` should be a `{field_type}` type.") + + if error_found: + return False + return True + + +event_names = set() +event_dates = set() + + +def check_structure(event: dict, idx: int): + if not require_field_check( + event, + idx, + [ + ("event", str), + ("triggerDate", dict), + ("status_index", str), + ("goodFortunes", dict), + ("badFortunes", dict), + ], + ): + return False + + event_name: str = event["event"] + if event_name.strip() == "": + errors[idx].append("event name should not empty.") + return + + if event_name in event_names: + errors[idx].append(f"event `{event_name}` already exists.") + + validate_number( + idx, event["status_index"], MIN_STATUS_INDEX, MAX_STATUS_INDEX, "status_index" + ) + + if require_field_check( + event["goodFortunes"], + idx, + [ + ("l_1_event", str), + ("l_1_desc", str), + ("l_2_event", str), + ("l_2_desc", str), + ], + "goodFortunes" + ): + if bool(event["goodFortunes"]["l_1_event"]) ^ bool(event["goodFortunes"]["l_1_desc"]): + # Check for inconsistency: XOR is used to ensure both l_1_event and l_1_desc + # are either both provided or both missing. If only one is provided, log an error. + errors[idx].append("First good fortune is incomplete.") + + if bool(event["goodFortunes"]["l_2_event"]) ^ bool(event["goodFortunes"]["l_2_desc"]): + # Check for inconsistency: XOR is used to ensure both l_2_event and l_2_desc + # are either both provided or both missing. If only one is provided, log an error. + errors[idx].append("Second good fortune is incomplete.") + + if require_field_check( + event["badFortunes"], + idx, + [ + ("r_1_event", str), + ("r_1_desc", str), + ("r_2_event", str), + ("r_2_desc", str), + ], + "badFortunes" + ): + if bool(event["badFortunes"]["r_1_event"]) ^ bool(event["badFortunes"]["r_1_desc"]): + # Check for inconsistency: XOR is used to ensure both r_1_event and r_1_desc + # are either both provided or both missing. If only one is provided, log an error. + errors[idx].append("First bad fortune is incomplete.") + + if bool(event["badFortunes"]["r_2_event"]) ^ bool(event["badFortunes"]["r_2_desc"]): + # Check for inconsistency: XOR is used to ensure both r_2_event and r_2_desc + # are either both provided or both missing. If only one is provided, log an error. + errors[idx].append("Second bad fortune is incomplete.") + + event_names.add(event_name) + + return True + +def check_static_date(event: dict, idx: int): + trigger_date: dict = event["triggerDate"] + corrected = require_field_check( + trigger_date, + idx, + [ + ("month", str), + ("date", str), + ], + "triggerDate", + ) + + event_name: str = event["event"] + if "year" in trigger_date: + errors[idx].append( + f"this event `{event_name}` should be placed in `custom_special.json`." + ) + + if "week" in trigger_date or "weekday" in trigger_date: + errors[idx].append( + f"this event `{event_name}` should be placed in `cyclical_special.json`." + ) + + if not corrected: + return + + month = validate_number(idx, trigger_date["month"], 1, 12, "triggerDate.month") + if month is not None: + validate_number( + idx, trigger_date["date"], 1, DAYSPERMONTH[month], "triggerDate.date" + ) + + +def check_cyclical_date(event: dict, idx: int): + trigger_date: dict = event["triggerDate"] + corrected = require_field_check( + trigger_date, + idx, + [ + ("month", str), + ("week", str), + ("weekday", str), + ], + "triggerDate", + ) + + event_name: str = event["event"] + if "year" in trigger_date: + errors[idx].append( + f"this event `{event_name}` should be placed in `custom_special.json`." + ) + + elif "date" in trigger_date: + errors[idx].append( + f"this event `{event_name}` should be placed in `static_special.json`." + ) + + if not corrected: + return + + validate_number(idx, trigger_date["month"], 1, 12, "triggerDate.month") + validate_number(idx, trigger_date["week"], 1, 5, "triggerDate.week") + validate_number(idx, trigger_date["weekday"], 1, 7, "triggerDate.weekday") + + +def check_custom_date(event: dict, idx: int): + trigger_date: dict = event["triggerDate"] + corrected = require_field_check( + trigger_date, + idx, + [ + ("year", str), + ("month", str), + ("date", str), + ], + "triggerDate", + ) + + event_name: str = event["event"] + if "week" in trigger_date or "weekday" in trigger_date: + errors[idx].append( + f"this event `{event_name}` should be placed in `cyclical_special.json`.", + ) + + elif "year" not in trigger_date: + errors[idx].append( + f"this event `{event_name}` should be placed in `static_special.json`." + ) + + if not corrected: + return + + year = validate_number( + idx, + trigger_date["year"], + datetime.datetime.min.year, + datetime.datetime.max.year, + "triggerDate.year", + ) + month = validate_number(idx, trigger_date["month"], 1, 12, "triggerDate.month") + + if year is None or month is None: + return + + days = DAYSPERMONTH[month] + if month == 2 and is_leap_year(year): + days += 1 # 29 + + date = validate_number(idx, trigger_date["date"], 1, days, "triggerDate.date") + if date is None: + return + + date_str = f"{year}/{month}/{date}" + if date_str in event_dates: + errors[idx].append(f"The date `{date_str}` of `{event_name}` is repeated.") + + event_dates.add(date_str) + + +date_checker = { + DateType.CUSTOM: check_custom_date, + DateType.STATIC: check_static_date, + DateType.CYCLICAL: check_cyclical_date, +} +check_triggerdate = date_checker[args.type] + +for idx, event in enumerate(special_events["special_events"]): + if check_structure(event, idx): + check_triggerdate(event, idx) + +if errors: + logging.error(args.path) + for idx, error_msgs in errors.items(): + logging.error(json.dumps(special_events["special_events"][idx], indent=4)) + for msg in error_msgs: + logging.error(msg) + exit(-1) -- 2.49.1 From 6adb7f4eae995a0c5a5d85e25e4eae60770babb4 Mon Sep 17 00:00:00 2001 From: tobiichi3227 Date: Mon, 3 Feb 2025 22:38:57 +0800 Subject: [PATCH 2/2] Chore(Fortune): Move all scripts from `dev` to `scripts` and remove outdated scripts --- .github/workflows/check-events.yml | 6 +- dev/.gitignore | 2 - dev/main.js | 289 ----------------------------- dev/plot_gen.py | 40 ---- {dev => scripts}/check-events.py | 0 5 files changed, 3 insertions(+), 334 deletions(-) delete mode 100644 dev/.gitignore delete mode 100644 dev/main.js delete mode 100644 dev/plot_gen.py rename {dev => scripts}/check-events.py (100%) diff --git a/.github/workflows/check-events.yml b/.github/workflows/check-events.yml index 880f280..2616ba9 100644 --- a/.github/workflows/check-events.yml +++ b/.github/workflows/check-events.yml @@ -24,12 +24,12 @@ jobs: - uses: actions/setup-python@v5 - name: Check Custom Special Events run: | - python3 dev/check-events.py fortune_generator/json/custom_special.json custom + python3 scripts/check-events.py fortune_generator/json/custom_special.json custom - name: Check Static Special Events run: | - python3 dev/check-events.py fortune_generator/json/static_special.json static + python3 scripts/check-events.py fortune_generator/json/static_special.json static - name: Check Cyclical Special Events run: | - python3 dev/check-events.py fortune_generator/json/cyclical_special.json cyclical + python3 scripts/check-events.py fortune_generator/json/cyclical_special.json cyclical diff --git a/dev/.gitignore b/dev/.gitignore deleted file mode 100644 index aa165e7..0000000 --- a/dev/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -good_fortune_data.txt -bad_fortune_data.txt \ No newline at end of file diff --git a/dev/main.js b/dev/main.js deleted file mode 100644 index 4a9d501..0000000 --- a/dev/main.js +++ /dev/null @@ -1,289 +0,0 @@ -const fs = require("fs"); - -const goodFortunes = [ - { - "event": "睡覺", - "description": "品質良好,精神煥發", - }, - { - "event": "做家務", - "description": "整潔使人心情愉悅", - }, - { - "event": "冥想", - "description": "平靜心靈,緩解焦慮", - }, - { - "event": "攝影", - "description": "捕捉到美好瞬間", - }, - { - "event": "喝咖啡", - "description": "精力充沛燃燒脂肪", - }, - { - "event": "朋友聚會", - "description": "充滿歡笑和美好回憶", - }, - { - "event": "體育鍛鍊", - "description": "能量滿滿,效果顯著", - }, - { - "event": "出遊", - "description": "好天氣,好心情", - }, - { - "event": "吃大餐", - "description": "聯絡感情", - }, - { - "event": "逛書店", - "description": "新書上架,打折推銷", - }, - { - "event": "學新技能", - "description": "快速上手", - }, - { - "event": "唱歌", - "description": "被星探發掘", - }, - { - "event": "上課", - "description": "整天不累,100% 消化", - }, - { - "event": "洗澡", - "description": "重獲能量", - }, - { - "event": "請教問題", - "description": "問題皆獲高人指點", - }, - { - "event": "網購", - "description": "心儀商品皆促銷", - }, - { - "event": "放假", - "description": "休息充電,明日再戰", - }, - { - "event": "早睡", - "description": "好夢連連", - }, - { - "event": "早起", - "description": "朝氣蓬勃,神采飛揚", - }, - { - "event": "發文章", - "description": "瀏覽數暴增", - }, - { - "event": "點外賣", - "description": "準時到達,新鮮好吃", - }, - { - "event": "做善事", - "description": "積善成福", - }, - { - "event": "散步", - "description": "空氣良好,放鬆身心", - }, -]; - -const badFortunes = [ - { - "event": "體育鍛鍊", - "description": "不慎受傷", - }, - { - "event": "攝影", - "description": "照片全消失", - }, - { - "event": "出遊", - "description": "天氣不晴朗", - }, - { - "event": "吃大餐", - "description": "被要求請客", - }, - { - "event": "學新技能", - "description": "屢試不爽,始終不懂", - }, - { - "event": "唱歌", - "description": "嗓子發炎", - }, - { - "event": "洗澡", - "description": "水溫不穩", - }, - { - "event": "請教問題", - "description": "疑難雜症,均無解答", - }, - { - "event": "網購", - "description": "錯過促銷", - }, - { - "event": "放假", - "description": "隔日工作量倍增", - }, - { - "event": "晚睡", - "description": "失眠,明日精神渙散", - }, - { - "event": "晚起", - "description": "整天都不順", - }, - { - "event": "發文章", - "description": "搜索枯腸,不知所云", - }, - { - "event": "點外賣", - "description": "路況壅塞,餐點冷掉", - }, - { - "event": "喝咖啡", - "description": "晚上失眠", - }, - { - "event": "散步", - "description": "被害蟲咬傷", - }, -]; - -const badLen = badFortunes.length; -const goodLen = goodFortunes.length; -let num = null; -const dates = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 30]; -const statusLen = 8; -let buckets = {}; -let day = 0; -let run_cnt = 0; - -while (run_cnt != 2000) { - let n1 = parseInt(Math.random() * 255 + 1); - let n2 = parseInt(Math.random() * 255 + 1); - let n3 = parseInt(Math.random() * 255 + 1); - let n4 = parseInt(Math.random() * 255 + 1); - - if (!check_ip_valid(n1, n2, n3, n4)) continue; - - let index = `${n1}.${n2}.${n3}.${n4}`; - buckets[index] = [0, 0, 0, 0]; - for (let i = 1; i <= 12; i++) { - for (let j = 1; j <= dates[i - 1]; j++) { - day %= 7; - run(2023, i, j, day, [n1, n2, n3, n4]); - day++; - } - } - - run_cnt++; -} - -fs.writeFile("./res.txt", JSON.stringify(buckets), (err) => { - console.log(err); -}); - -function check_ip_valid(n1, n2, n3, n4) { - if (n1 > 255 || n2 > 255 || n3 > 255 || n4 > 255) return false; - - // private network - if (n1 === 10) return false; - - // Carrier-grade NAT - if (n1 == 100 && n2 == 64) return false; - - // localhost - if (n1 === 127 && n2 === 0 && n3 === 0) return false; - - // link-local address - if (n1 == 169 && n2 == 254) return false; - - // private network - if (n1 === 172) { if (n2 >= 16 && n2 <= 31) return false; } - - if (n1 === 192) { - if (n2 === 168) return false; // private network - if (n2 === 0 && n3 === 0) return false; // IANA RFC 5735 - if (n2 === 0 && n3 === 2) return false; // TEST-NET-1 RFC 5735 - if (n2 === 88 && n3 === 99) return false; // 6to4 - } - - if (n1 == 198) { - if (n2 == 18) return false; // RFC 2544 - if (n2 == 51 && n3 == 100) return false; // TEST-NET-2 RFC 5735 - } - - if (n1 == 203 && n3 == 113) return false; // TEST-NET-3 RFC 5735 - - // class D network - if (n1 == 224) return false; - - // class E network - if (n1 == 255) return false; - - return true; -} - -// calculate hash and write result -function run(year, month, date, day, ip) { - let num = ip; - let index = `${ip[0]}.${ip[1]}.${ip[2]}.${ip[3]}`; - - // original hash function - let hashDate = Math.round( - Math.log10( - year * - ((month << (Math.log10(num[3]) + day - 1)) * - (date << Math.log10(num[2] << day))), - ), - ); - let seed1 = (num[0] >> hashDate) * (num[1] >> Math.min(hashDate, 2)) + - (num[2] << 1) * (num[3] >> 3) + (date << 3) * (month << hashDate) + - ((year * day) >> 2); - let seed2 = (num[0] << (hashDate + 2)) * (num[1] << hashDate) + - (num[2] << 1) * (num[3] << 3) + (date << (hashDate - 1)) * (month << 4) + - (year >> hashDate) + ((date * day) >> 1); - - // make sure the events won't collide - let set = new Set(); - let l1 = (seed1 % goodLen + goodLen) % goodLen; - let l2 = (((seed1 << 1) + date) % goodLen + goodLen) % goodLen; - - while (l1 == l2) { - l2 = (l2 + 1) % goodLen; - } - - set.add(goodFortunes[l1].event); - set.add(goodFortunes[l2].event); - - let r1 = (((seed1 >> 1) + (month << 3)) % badLen + badLen) % badLen; - while (set.has(badFortunes[r1].event)) { - r1 = (r1 + 2) % badLen; - } - set.add(badFortunes[r1].event); - let r2 = - ((((((seed1 << 3) + (year >> 5) * (date << 2)) % badLen) * seed2) >> 6) % - badLen + badLen) % badLen; - while (set.has(badFortunes[r2].event)) { - r2 = (r2 + 1) % badLen; - } - - // write l1, l2, r1, r2 - buckets[index][0] = l1; - buckets[index][1] = l2; - buckets[index][2] = r1; - buckets[index][3] = r2; -} diff --git a/dev/plot_gen.py b/dev/plot_gen.py deleted file mode 100644 index da7e757..0000000 --- a/dev/plot_gen.py +++ /dev/null @@ -1,40 +0,0 @@ -import matplotlib.pyplot as plt - -# Data Processing -groups = 2 - -with open('./good_fortune_data.txt', 'r') as f: - good_fortune_data = [int(line.split(' ')[1].strip()) for line in f.readlines()] - good_fortune_data_len = len(good_fortune_data) // groups - -fig, axs = plt.subplots(groups, 1, figsize=(8, 6)) - -axs[0].bar(range(good_fortune_data_len), good_fortune_data[:good_fortune_data_len], color='skyblue', edgecolor='black') -axs[0].set_xlabel("Good Fortune Event Index") -axs[0].set_ylabel("Occurrences") - -axs[1].bar(range(good_fortune_data_len), good_fortune_data[good_fortune_data_len:], color='skyblue', edgecolor='black') -axs[1].set_xlabel("Good Fortune Event Index") -axs[1].set_ylabel("Occurrences") - -plt.tight_layout() - -plt.savefig("../docs/good_fortune_statistics.png") - -with open('./bad_fortune_data.txt', 'r') as f: - bad_fortune_data = [int(line.split(' ')[1].strip()) for line in f.readlines()] - bad_fortune_data_len = len(bad_fortune_data) // groups - -fig, axs = plt.subplots(groups, 1, figsize=(8, 6)) - -axs[0].bar(range(bad_fortune_data_len), bad_fortune_data[:bad_fortune_data_len], color='skyblue', edgecolor='black') -axs[0].set_xlabel("Bad Fortune Event Index") -axs[0].set_ylabel("Occurrences") - -axs[1].bar(range(bad_fortune_data_len), bad_fortune_data[bad_fortune_data_len:], color='skyblue', edgecolor='black') -axs[1].set_xlabel("Bad Fortune Event Index") -axs[1].set_ylabel("Occurrences") - -plt.tight_layout() - -plt.savefig("../docs/bad_fortune_statistics.png") \ No newline at end of file diff --git a/dev/check-events.py b/scripts/check-events.py similarity index 100% rename from dev/check-events.py rename to scripts/check-events.py -- 2.49.1