diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index 6b1e85c..a309ee8 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run actionlint shell: bash @@ -22,12 +22,12 @@ jobs: ./actionlint -color - name: setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "18" - name: cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} @@ -61,7 +61,7 @@ jobs: run: | set -ex echo "${{ secrets.DEV_GCLOUD_SERVICE_KEY }}" | base64 -d > ${{ env.GCP_KEY_PATH }} - - uses: 'google-github-actions/auth@v1' + - uses: 'google-github-actions/auth@v2' with: credentials_json: '${{ secrets.DEV_GCLOUD_SERVICE_KEY }}' @@ -69,7 +69,7 @@ jobs: run: 'gcloud auth list --filter=status:ACTIVE --format="value(account)"' - name: 'Set up Cloud SDK' - uses: 'google-github-actions/setup-gcloud@v1' + uses: 'google-github-actions/setup-gcloud@v2' with: install_components: '' project_id: 'd-shrine-dev' diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 3cd40c5..f146f0b 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -12,7 +12,7 @@ jobs: deploy: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run actionlint shell: bash @@ -22,12 +22,12 @@ jobs: ./actionlint -color - name: setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: "18" - name: cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} @@ -64,14 +64,14 @@ jobs: run: | set -ex echo "${{ secrets.PROD_GCLOUD_SERVICE_KEY }}" | base64 -d > ${{ env.GCP_KEY_PATH }} - - uses: 'google-github-actions/auth@v1' + - uses: 'google-github-actions/auth@v2' with: credentials_json: '${{ secrets.PROD_GCLOUD_SERVICE_KEY }}' - name: 'Use gcloud CLI' run: 'gcloud auth list --filter=status:ACTIVE --format="value(account)"' - name: 'Set up Cloud SDK' - uses: 'google-github-actions/setup-gcloud@v1' + uses: 'google-github-actions/setup-gcloud@v2' with: install_components: '' project_id: 'd-shrine' diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml index 373a034..8696a09 100644 --- a/.github/workflows/setup.yml +++ b/.github/workflows/setup.yml @@ -10,7 +10,7 @@ jobs: actions-test: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run actionlint shell: bash @@ -22,15 +22,15 @@ jobs: build: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "18" - name: cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} @@ -61,3 +61,31 @@ jobs: npm install -g firebase-tools npm install working-directory: ./app/functions + + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: cache + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + # テストは performance.js(moment のみ依存)を検証するため、 + # canvas 等のネイティブビルド(OGP生成専用)は不要。--ignore-scripts で高速・堅牢化する。 + - name: install functions deps + run: npm install --ignore-scripts + working-directory: ./app/functions + + - name: run functions tests + run: npm test + working-directory: ./app/functions diff --git a/app/functions/index.js b/app/functions/index.js index fead21f..741349d 100644 --- a/app/functions/index.js +++ b/app/functions/index.js @@ -10,6 +10,13 @@ const { ChartJSNodeCanvas } = require("chartjs-node-canvas") const cors = require("cors")({ origin: true }) +const { + user_performance, + user_formatted_performance, + raw_user_data_from_status, + latest_activity_created_at, + compute_performance_increment +} = require("./performance") const projectID = process.env.GCLOUD_PROJECT const buggetName = `${projectID}.appspot.com` @@ -37,7 +44,6 @@ const fontStyle = { lineHeight: 100, color: "#FFFFFF" } -const target_points = [0,5,11,19,30,45,65,91,124,166,218,281,357,447,553,676,818,981,1167,1378,1616,1884,2184,2519,2892,3306,3764,4269,4825,5436,6106,6840,7643,8520,9477,10520,11656,12892,14236,15696,17281,19001,20867,22891,25086,27466,30046,32842,35872,39156] const date_now = (moment()).unix() const date_low = moment("2022-01-01T00:00:00Z").subtract(9, 'hours').unix() const date_max = moment("2022-01-04T00:00:00Z").subtract(9, 'hours').unix() @@ -60,27 +66,6 @@ const sanpai = { // Create and Deploy Your First Cloud Functions // https://firebase.google.com/docs/functions/write-firebase-functions -function get_level(points) { - level = 0 - for (let i=0; i < target_points.length; i++) { - if (points <= target_points[i]) { - level = i + 1 - break - } - } - return level -} - -function get_next_leve_exp(points) { - let level = get_level(points) - let return_data = { - next_level: level + 1, - next_exp: target_points[level] - } - return return_data -} - - async function get_feed(user, per_page=100) { functions.logger.info("get_feed") try { @@ -204,135 +189,6 @@ async function get_activity_list(userRef) { } -function user_performance(items, username) { - let user_data = { - user: username, - hp: 0, - power: 0, - defence: 0, - dex: 0, - agility: 0, - intelligence: 0 - } - - - previousItem = null - continuous_count = 0 - let sorted_item = items.sort(function(a, b) { - return (moment(a.created_at).unix() < moment(b.created_at).unix()) ? -1 : 1 - }) - for (const item of sorted_item) { - if (previousItem) { - previous_time = moment(previousItem.created_at) - current_time = moment(item.created_at) - diff = current_time.diff(previous_time)/1000 - if (30 < diff && diff <= 120) { - user_data.agility += 6 - } else if (diff <= 180) { - user_data.agility += 3 - } else if (diff <= 300) { - user_data.agility += 2 - } else if (diff <= 1200) { - user_data.agility += 1 - } - if (diff <= 7200) { - continuous_count++ - } else { - user_data.hp += continuous_count * 2 - continuous_count = 0 - } - } - switch (item.type) { - case "ForkEvent": - user_data.power += 1 - break - case "PushEvent": - user_data.power += 2 - break - case "CreateEvent": - case "DeleteEvent": - user_data.power += 1 - break - case "PullRequestEvent": - user_data.power += 3 - break - case "IssuesEvent": - switch (item.payload) { - case "opened": - user_data.intelligence += 3 - break - case "closed": - user_data.defence += 5 - break - } - break - case "IssueCommentEvent": - user_data.intelligence += 2 - break - case "PullRequestReviewEvent": - user_data.defence += 3 - break - case "PullRequestReviewCommentEvent": - user_data.defence += 3 - break - case "GollumEvent": - user_data.defence += 3 - break - case "ReleaseEvent": - user_data.defence += 10 - break - } - previousItem = item - } - if (continuous_count > 0) { - user_data.hp += continuous_count * 2 - } - - return user_data -} - -function user_formatted_performance(user_data, append_data={}) { - let return_Data = { - user: user_data.user, - points: 0, - hp: user_data.hp, - power: user_data.power, - intelligence: user_data.intelligence, - defence: user_data.defence, - agility: user_data.agility, - total: user_data.hp + user_data.power + user_data.intelligence + user_data.defence + user_data.agility, - level: 0, - exp: 0, - next_exp: 0, - chart: { - hp: 0, - power: 0, - intelligence: 0, - defence: 0, - agility: 0 - } - } - // 経験値を反映 - if(append_data.exp) { - return_Data.exp += append_data.exp - return_Data.points = append_data.exp - } - if(append_data.user) { - return_Data.user = append_data.user - } - - return_Data.chart.hp = return_Data.hp - return_Data.chart.power = return_Data.power, - return_Data.chart.intelligence = return_Data.intelligence - return_Data.chart.defence = return_Data.defence - return_Data.chart.agility = return_Data.agility - - return_Data.level = get_level(return_Data.total) - return_Data.next_exp = get_next_leve_exp(return_Data.total).next_exp - return return_Data -} - - async function get_ranking(db, screen_name) { const rankingCache = await db.collection("cache_data").doc("ranking_cache").get() let response = {} @@ -1018,14 +874,31 @@ exports.sanpai = functions.https.onRequest(async(request, response) => { github_image_path: userData.image_path } - const raw_activities_list = await get_activity_list(userRef) + let raw_user_data + let last_activity_created_at + if (userData.status && userData.last_activity_created_at) { + // 保存済みステータスに新着分だけを加算(全件再集計しない)。 + // splited_items は「created_at > last_sanpai」で抽出した未集計イベントのみ、 + // last_activity_created_at は累積済みイベントの最大時刻であり、 + // compute_performance_increment の不変条件(新着は累積分より後)を満たす。 + const base_user_data = raw_user_data_from_status(userData.status, userData.screen_name) + const increment = compute_performance_increment(base_user_data, splited_items, userData.last_activity_created_at) + raw_user_data = increment.user_data + last_activity_created_at = increment.last_created_at + } else { + // 初回(status未保存)は全アクティビティから計算し、増分計算の基準を初期化する + const raw_activities_list = await get_activity_list(userRef) + raw_user_data = user_performance(raw_activities_list, userData.screen_name) + last_activity_created_at = latest_activity_created_at(raw_activities_list) + } - userStatusData = user_formatted_performance(user_performance(raw_activities_list, userData.screen_name), userAppendData) + userStatusData = user_formatted_performance(raw_user_data, userAppendData) await userRef.update({ last_sanpai: FieldValue.serverTimestamp(), exp: FieldValue.increment(add_exp), - status: userStatusData + status: userStatusData, + last_activity_created_at: last_activity_created_at }) let return_data = { status: "success", diff --git a/app/functions/package.json b/app/functions/package.json index 5dd8917..f14d3e9 100644 --- a/app/functions/package.json +++ b/app/functions/package.json @@ -6,7 +6,8 @@ "shell": "firebase functions:shell", "start": "npm run shell", "deploy": "firebase deploy --only functions", - "logs": "firebase functions:log" + "logs": "firebase functions:log", + "test": "node --test" }, "engines": { "node": "18" diff --git a/app/functions/performance.js b/app/functions/performance.js new file mode 100644 index 0000000..6714356 --- /dev/null +++ b/app/functions/performance.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node +// 参拝の能力解析(パフォーマンス計算)に関する純粋ロジック。 +// 副作用(Firestore/HTTP/認証)を持たず、ユニットテスト可能な単位として index.js から分離している。 +const moment = require("moment") + +const target_points = [0,5,11,19,30,45,65,91,124,166,218,281,357,447,553,676,818,981,1167,1378,1616,1884,2184,2519,2892,3306,3764,4269,4825,5436,6106,6840,7643,8520,9477,10520,11656,12892,14236,15696,17281,19001,20867,22891,25086,27466,30046,32842,35872,39156] + +function get_level(points) { + let level = 0 + for (let i=0; i < target_points.length; i++) { + if (points <= target_points[i]) { + level = i + 1 + break + } + } + return level +} + +function get_next_leve_exp(points) { + let level = get_level(points) + let return_data = { + next_level: level + 1, + next_exp: target_points[level] + } + return return_data +} + +function user_performance(items, username) { + let user_data = { + user: username, + hp: 0, + power: 0, + defence: 0, + dex: 0, + agility: 0, + intelligence: 0 + } + + + let previousItem = null + let continuous_count = 0 + let sorted_item = items.sort(function(a, b) { + return (moment(a.created_at).unix() < moment(b.created_at).unix()) ? -1 : 1 + }) + for (const item of sorted_item) { + if (previousItem) { + let previous_time = moment(previousItem.created_at) + let current_time = moment(item.created_at) + let diff = current_time.diff(previous_time)/1000 + if (30 < diff && diff <= 120) { + user_data.agility += 6 + } else if (diff <= 180) { + user_data.agility += 3 + } else if (diff <= 300) { + user_data.agility += 2 + } else if (diff <= 1200) { + user_data.agility += 1 + } + if (diff <= 7200) { + continuous_count++ + } else { + user_data.hp += continuous_count * 2 + continuous_count = 0 + } + } + switch (item.type) { + case "ForkEvent": + user_data.power += 1 + break + case "PushEvent": + user_data.power += 2 + break + case "CreateEvent": + case "DeleteEvent": + user_data.power += 1 + break + case "PullRequestEvent": + user_data.power += 3 + break + case "IssuesEvent": + switch (item.payload) { + case "opened": + user_data.intelligence += 3 + break + case "closed": + user_data.defence += 5 + break + } + break + case "IssueCommentEvent": + user_data.intelligence += 2 + break + case "PullRequestReviewEvent": + user_data.defence += 3 + break + case "PullRequestReviewCommentEvent": + user_data.defence += 3 + break + case "GollumEvent": + user_data.defence += 3 + break + case "ReleaseEvent": + user_data.defence += 10 + break + } + previousItem = item + } + if (continuous_count > 0) { + user_data.hp += continuous_count * 2 + } + + return user_data +} + +function user_formatted_performance(user_data, append_data={}) { + let return_Data = { + user: user_data.user, + points: 0, + hp: user_data.hp, + power: user_data.power, + intelligence: user_data.intelligence, + defence: user_data.defence, + agility: user_data.agility, + total: user_data.hp + user_data.power + user_data.intelligence + user_data.defence + user_data.agility, + level: 0, + exp: 0, + next_exp: 0, + chart: { + hp: 0, + power: 0, + intelligence: 0, + defence: 0, + agility: 0 + } + } + // 経験値を反映 + if(append_data.exp) { + return_Data.exp += append_data.exp + return_Data.points = append_data.exp + } + if(append_data.user) { + return_Data.user = append_data.user + } + + return_Data.chart.hp = return_Data.hp + return_Data.chart.power = return_Data.power, + return_Data.chart.intelligence = return_Data.intelligence + return_Data.chart.defence = return_Data.defence + return_Data.chart.agility = return_Data.agility + + return_Data.level = get_level(return_Data.total) + return_Data.next_exp = get_next_leve_exp(return_Data.total).next_exp + return return_Data +} + + +// 保存済み status から user_performance 相当の生ステータスを復元する +function raw_user_data_from_status(status, username) { + return { + user: username, + hp: status.hp, + power: status.power, + defence: status.defence, + dex: 0, + agility: status.agility, + intelligence: status.intelligence + } +} + +// アクティビティ群の中で最も新しい created_at を返す(無ければ null) +function latest_activity_created_at(items) { + return items.reduce((latest, item) => { + if (!latest) return item.created_at + return (moment(item.created_at).unix() > moment(latest).unix()) ? item.created_at : latest + }, null) +} + +// 累積ステータス(base_user_data)に新着アクティビティ分だけを加算する増分計算。 +// user_performance の per-event / per-pair の寄与は加算的で、バッチ境界 +// (previous_created_at と新着先頭の時間差)だけがクロスバッチ依存となるため、 +// 全件を再集計せずとも全件計算と同一の結果が得られる。 +// hp は「diff<=7200秒のペア数*2」であり、user_performance の continuous_count*2 と等価。 +// +// 【前提となる不変条件】全件計算と一致するのは以下が成り立つ場合のみ: +// 1. new_items は base_user_data に未集計のイベントだけで構成される(二重計上しない)。 +// 2. new_items の全イベントが previous_created_at(=累積済みイベントの最大時刻)より後である。 +// 3. previous_created_at は累積済みイベントの最大 created_at である。 +// 呼び出し側(sanpai)は「created_at > last_sanpai」で new_items を抽出し、 +// previous_created_at に保存済み最大時刻(last_activity_created_at)を渡すことでこれを満たす。 +// この前提が崩れると全件計算と不一致になり得るため、崩れた場合は警告ログを出す。 +function compute_performance_increment(base_user_data, new_items, previous_created_at) { + let user_data = { + user: base_user_data.user, + hp: base_user_data.hp, + power: base_user_data.power, + defence: base_user_data.defence, + dex: base_user_data.dex || 0, + agility: base_user_data.agility, + intelligence: base_user_data.intelligence + } + let sorted_items = [...new_items].sort(function(a, b) { + return (moment(a.created_at).unix() < moment(b.created_at).unix()) ? -1 : 1 + }) + // 不変条件2の検知: 新着の最古イベントが境界より前なら前提が崩れている + if (previous_created_at && sorted_items.length > 0 && + moment(sorted_items[0].created_at).unix() < moment(previous_created_at).unix()) { + console.warn(`[performance] 増分計算の前提違反: 新着の最古イベント(${sorted_items[0].created_at})が境界(${previous_created_at})より前です。全件計算と不一致になり得ます。`) + } + let prev_created_at = previous_created_at + for (const item of sorted_items) { + if (prev_created_at) { + let diff = moment(item.created_at).diff(moment(prev_created_at)) / 1000 + if (30 < diff && diff <= 120) { + user_data.agility += 6 + } else if (diff <= 180) { + user_data.agility += 3 + } else if (diff <= 300) { + user_data.agility += 2 + } else if (diff <= 1200) { + user_data.agility += 1 + } + if (diff <= 7200) { + user_data.hp += 2 + } + } + switch (item.type) { + case "ForkEvent": + user_data.power += 1 + break + case "PushEvent": + user_data.power += 2 + break + case "CreateEvent": + case "DeleteEvent": + user_data.power += 1 + break + case "PullRequestEvent": + user_data.power += 3 + break + case "IssuesEvent": + switch (item.payload) { + case "opened": + user_data.intelligence += 3 + break + case "closed": + user_data.defence += 5 + break + } + break + case "IssueCommentEvent": + user_data.intelligence += 2 + break + case "PullRequestReviewEvent": + user_data.defence += 3 + break + case "PullRequestReviewCommentEvent": + user_data.defence += 3 + break + case "GollumEvent": + user_data.defence += 3 + break + case "ReleaseEvent": + user_data.defence += 10 + break + } + prev_created_at = item.created_at + } + return { user_data: user_data, last_created_at: prev_created_at } +} + +module.exports = { + target_points, + get_level, + get_next_leve_exp, + user_performance, + user_formatted_performance, + raw_user_data_from_status, + latest_activity_created_at, + compute_performance_increment +} diff --git a/app/functions/test/performance.test.js b/app/functions/test/performance.test.js new file mode 100644 index 0000000..2d17c50 --- /dev/null +++ b/app/functions/test/performance.test.js @@ -0,0 +1,228 @@ +const test = require("node:test") +const assert = require("node:assert") +const moment = require("moment") +const perf = require("../performance") + +const { + get_level, + get_next_leve_exp, + user_performance, + user_formatted_performance, + raw_user_data_from_status, + latest_activity_created_at, + compute_performance_increment +} = perf + +// ---- helpers ---- +function iso(unix) { + return moment.unix(unix).utc().format("YYYY-MM-DDTHH:mm:ss") + "Z" +} +let _seq = 0 +function item(type, unix, payload = null) { + return { id: `${unix}_${_seq++}`, type, payload, created_at: iso(unix) } +} + +// ============================================================ +// get_level / get_next_leve_exp +// ============================================================ +test("get_level: しきい値の境界", () => { + assert.strictEqual(get_level(0), 1) // target_points[0]=0 + assert.strictEqual(get_level(5), 2) // target_points[1]=5 + assert.strictEqual(get_level(6), 3) // 6<=11 + assert.strictEqual(get_level(11), 3) + assert.strictEqual(get_level(12), 4) // 12<=19 +}) + +test("get_next_leve_exp: 次レベルと必要経験値", () => { + const r = get_next_leve_exp(0) // level=1 -> target_points[1]=5 + assert.strictEqual(r.next_level, 2) + assert.strictEqual(r.next_exp, 5) +}) + +// ============================================================ +// user_performance: イベント種別ごとの加点 +// ============================================================ +test("user_performance: 単一イベントの種別加点", () => { + assert.strictEqual(user_performance([item("PushEvent", 1000)], "u").power, 2) + assert.strictEqual(user_performance([item("ForkEvent", 1000)], "u").power, 1) + assert.strictEqual(user_performance([item("CreateEvent", 1000)], "u").power, 1) + assert.strictEqual(user_performance([item("DeleteEvent", 1000)], "u").power, 1) + assert.strictEqual(user_performance([item("PullRequestEvent", 1000)], "u").power, 3) + assert.strictEqual(user_performance([item("IssueCommentEvent", 1000)], "u").intelligence, 2) + assert.strictEqual(user_performance([item("PullRequestReviewEvent", 1000)], "u").defence, 3) + assert.strictEqual(user_performance([item("PullRequestReviewCommentEvent", 1000)], "u").defence, 3) + assert.strictEqual(user_performance([item("GollumEvent", 1000)], "u").defence, 3) + assert.strictEqual(user_performance([item("ReleaseEvent", 1000)], "u").defence, 10) +}) + +test("user_performance: 未対応イベントは加点しない", () => { + const r = user_performance([item("WatchEvent", 1000)], "u") + assert.strictEqual(r.power + r.defence + r.intelligence + r.agility + r.hp, 0) +}) + +test("user_performance: IssuesEvent は payload が文字列の時のみ加点(既存仕様)", () => { + // 文字列 payload の場合のみ switch にマッチする + assert.strictEqual(user_performance([item("IssuesEvent", 1000, "opened")], "u").intelligence, 3) + assert.strictEqual(user_performance([item("IssuesEvent", 1000, "closed")], "u").defence, 5) + // GitHub API 実体のオブジェクト payload はマッチせず加点されない(既存挙動) + const objR = user_performance([item("IssuesEvent", 1000, { action: "opened" })], "u") + assert.strictEqual(objR.intelligence, 0) + assert.strictEqual(objR.defence, 0) +}) + +// ============================================================ +// user_performance: 時間差による agility / hp +// ============================================================ +function twoPush(diffSec) { + return user_performance([item("PushEvent", 1000), item("PushEvent", 1000 + diffSec)], "u") +} + +test("user_performance: agility は連続イベントの時間差で決まる", () => { + assert.strictEqual(twoPush(60).agility, 6) // 301200 はどのバケットにも該当しない + assert.strictEqual(twoPush(30).agility, 3) // 30 { + assert.strictEqual(twoPush(60).hp, 2) // 連続1ペア -> hp 2 + assert.strictEqual(twoPush(7200).hp, 2) // 境界(<=7200) + assert.strictEqual(twoPush(7201).hp, 0) // ギャップ -> hp 0 + // 3連続(全て7200秒以内) -> 2ペア -> hp 4 + const three = user_performance([ + item("PushEvent", 1000), item("PushEvent", 2000), item("PushEvent", 3000) + ], "u") + assert.strictEqual(three.hp, 4) +}) + +// ============================================================ +// user_formatted_performance +// ============================================================ +test("user_formatted_performance: total/chart/level/exp/points", () => { + const raw = { user: "u", hp: 10, power: 4, intelligence: 2, defence: 3, agility: 6, dex: 0 } + const fmt = user_formatted_performance(raw, { exp: 100, user: { display_name: "d" } }) + assert.strictEqual(fmt.total, 10 + 4 + 2 + 3 + 6) + assert.strictEqual(fmt.exp, 100) + assert.strictEqual(fmt.points, 100) + assert.deepStrictEqual(fmt.chart, { hp: 10, power: 4, intelligence: 2, defence: 3, agility: 6 }) + assert.strictEqual(fmt.level, get_level(fmt.total)) + assert.deepStrictEqual(fmt.user, { display_name: "d" }) +}) + +// ============================================================ +// raw_user_data_from_status / latest_activity_created_at +// ============================================================ +test("raw_user_data_from_status: status から生ステータスを復元", () => { + const status = { hp: 1, power: 2, defence: 3, agility: 4, intelligence: 5 } + assert.deepStrictEqual(raw_user_data_from_status(status, "u"), { + user: "u", hp: 1, power: 2, defence: 3, dex: 0, agility: 4, intelligence: 5 + }) +}) + +test("latest_activity_created_at: 最新の created_at を返す", () => { + assert.strictEqual(latest_activity_created_at([]), null) + const items = [item("PushEvent", 3000), item("PushEvent", 1000), item("PushEvent", 5000)] + assert.strictEqual(latest_activity_created_at(items), iso(5000)) +}) + +test("compute_performance_increment: 不変条件違反(新着が境界より前)で警告を出す", () => { + const base = { user: "u", hp: 0, power: 0, defence: 0, agility: 0, intelligence: 0 } + const orig = console.warn + let warned = 0 + console.warn = () => { warned++ } + try { + // 境界より前の created_at を渡す + compute_performance_increment(base, [item("PushEvent", 1000)], iso(5000)) + assert.strictEqual(warned, 1) + // 境界より後なら警告は出ない + warned = 0 + compute_performance_increment(base, [item("PushEvent", 9000)], iso(5000)) + assert.strictEqual(warned, 0) + } finally { + console.warn = orig + } +}) + +// ============================================================ +// 増分計算の等価性(プロパティテスト) +// ============================================================ +const TYPES = ["ForkEvent","PushEvent","CreateEvent","DeleteEvent","PullRequestEvent","IssuesEvent","IssueCommentEvent","PullRequestReviewEvent","PullRequestReviewCommentEvent","GollumEvent","ReleaseEvent","WatchEvent"] +const PAYLOADS = [{ action: "opened" }, { action: "closed" }, "opened", "closed", null] +function rand(n) { return Math.floor(Math.random() * n) } + +function genItems(count, startUnix) { + let t = startUnix + let items = [] + for (let i = 0; i < count; i++) { + t += rand(10000) // 7200秒境界を跨ぐようばらつかせる + items.push(item(TYPES[rand(TYPES.length)], t, PAYLOADS[rand(PAYLOADS.length)])) + } + return items +} + +const KEYS = ["hp","power","intelligence","defence","agility","total","level","next_exp","points","exp"] +function pick(o) { const r = {}; for (const k of KEYS) r[k] = o[k]; return r } +const append = { exp: 42, user: { display_name: "d", screen_name: "s" } } + +// 保存済み status からの増分適用を 1 ステップ行う +function applyIncrement(prevStatus, prevTs, batch) { + if (prevStatus) { + const inc = compute_performance_increment(raw_user_data_from_status(prevStatus, "s"), batch, prevTs) + return { status: user_formatted_performance(inc.user_data, {}), ts: inc.last_created_at } + } + // 初回(status未保存)は全件計算で初期化 + return { + status: user_formatted_performance(user_performance(batch.slice(), {}), {}), + ts: latest_activity_created_at(batch) + } +} + +test("増分計算 == 全件計算 (2分割・多数試行)", () => { + for (let c = 0; c < 2000; c++) { + const all = genItems(1 + rand(40), moment("2020-01-01T00:00:00Z").unix() + rand(1000000)) + all.sort((a, b) => moment(a.created_at).unix() - moment(b.created_at).unix()) + const k = rand(all.length + 1) + const oldItems = all.slice(0, k) + const newItems = all.slice(k) + if (newItems.length === 0) continue + + const full = user_formatted_performance(user_performance(all.slice(), {}), append) + + let incFmt + if (oldItems.length > 0) { + const baseStatus = user_formatted_performance(user_performance(oldItems.slice(), {}), {}) + const inc = compute_performance_increment(raw_user_data_from_status(baseStatus, "s"), newItems, latest_activity_created_at(oldItems)) + incFmt = user_formatted_performance(inc.user_data, append) + } else { + incFmt = user_formatted_performance(user_performance(newItems.slice(), {}), append) + } + assert.deepStrictEqual(pick(incFmt), pick(full)) + } +}) + +test("増分計算 == 全件計算 (3バッチ逐次適用)", () => { + for (let c = 0; c < 1000; c++) { + const all = genItems(3 + rand(40), moment("2020-01-01T00:00:00Z").unix() + rand(1000000)) + all.sort((a, b) => moment(a.created_at).unix() - moment(b.created_at).unix()) + const p1 = rand(all.length), p2 = p1 + rand(all.length - p1) + const b1 = all.slice(0, p1), b2 = all.slice(p1, p2), b3 = all.slice(p2) + if (b3.length === 0) continue + + const full = user_formatted_performance(user_performance(all.slice(), {}), append) + + let s = null, ts = null + if (b1.length > 0) { const r = applyIncrement(s, ts, b1); s = r.status; ts = r.ts } + if (b2.length > 0) { const r = applyIncrement(s, ts, b2); s = r.status; ts = r.ts } + let finalFmt + if (s) { + const inc = compute_performance_increment(raw_user_data_from_status(s, "s"), b3, ts) + finalFmt = user_formatted_performance(inc.user_data, append) + } else { + finalFmt = user_formatted_performance(user_performance(b3.slice(), {}), append) + } + assert.deepStrictEqual(pick(finalFmt), pick(full)) + } +}) diff --git a/web/pages/sanpai.vue b/web/pages/sanpai.vue index 375d144..f8c1ec8 100644 --- a/web/pages/sanpai.vue +++ b/web/pages/sanpai.vue @@ -60,9 +60,19 @@