mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-08-09 00:35:59 +00:00
e67df72198
* docs(frontend): MkInputのパディング計算コメントを実装に合わせて修正 ResizeObserverによる監視に変更済みで「定期的に計算する」は事実と異なるため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(frontend): MkSelectのパディング計算コメントを実装に合わせて修正 ResizeObserverによる監視に変更済みで「定期的に計算する」は事実と異なるため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(frontend): rippleディレクティブの未使用WeakMapを削除 イベント解除はAbortControllerに移行済みで、handlersはどこからも参照されていないため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(frontend): use-noteの非reactiveな導出値からcomputedを外す rawNote / appearNote / $i.id はcomposableの生存期間中に変化しないため、 isMyRenote / parsed / urls / isLong / canRenote はcomputedにする必要がない。 preferの変更に追従する必要があるshowTickerのみcomputedのまま残す。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(frontend): use-noteのreact()の引数名をcreateReactionMockに変更 mock時にリアクション作成の代わりに呼ばれるコールバックであることが customCallbackという名前からは読み取れないため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(frontend): signup重複ユーザー名テストの規約同意前チェックを復活 Playwright移行時に、規約に同意する前は続けるボタンがdisabledであることの 検証が抜け落ちていたため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(frontend/test): タイムアウトの理由コメントを実際の指定箇所へ移動 タイムアウトを指定しているのはcloseUserSetupDialogの既定値側であり、 呼び出し側にコメントだけが残って浮いていたため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(backend): LogNormalizerの文字列切り詰めを線形走査に変更 二分探索は各ステップでslice + Buffer.byteLengthを行うため全体でO(n log n) だった。コードポイント単位で先頭から積み上げればO(n)で済み、サロゲート対の 巻き戻し処理も不要になる。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(backend): AiServiceをSensitiveMediaDetectionServiceにリネーム NSFW推論を本体で行わなくなり、外部サービス (sensitive-detector) への アダプタになったため、AiServiceという名前が実態と合わなくなっていた。 ログドメインも `ai` から `sensitive-media-detection` に変更する。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * clean up [ci skip] * test(backend): LogNormalizerの文字列切り詰めに境界ケースのテストを追加 線形走査への変更に対し、2/3/4バイト文字が切り詰め境界に来る場合と、 接尾辞が上限に収まらない場合の挙動を検証する。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(backend): LogNormalizerの用語を「サロゲート対」から「サロゲートペア」に統一 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(frontend): use-noteのshowTickerからcomputedを外す prefer.s は静的な値でreactiveではない (reactiveな設定値は prefer.r) ため、 computedにしても設定変更に追従せず、単なるキャッシュにしかなっていなかった。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(frontend): use-noteの不要なリアクティブラッパーを外す - hideByPlugin: プラグイン割り込み処理の内部で確定し以降変化しないためref不要 - hardMuted: mutedと違い解除操作がなく書き換わらないためref不要 - pleaseLoginContext: hostは定数、appearNoteは非reactiveなのでcomputed不要 $appearNote は useNoteCapture が reactive() で返す真にリアクティブな値、 muted はテンプレートから解除操作で書き換わるため、いずれもそのまま残す。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "test(backend): LogNormalizerの文字列切り詰めに境界ケースのテストを追加" This reverts commit7bd6152cca. * Revert "perf(backend): LogNormalizerの文字列切り詰めを線形走査に変更" This reverts commit3ec88c35aa. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 lines
5.8 KiB
TypeScript
151 lines
5.8 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
|
import type { MiMeta } from '@/models/_.js';
|
|
import type { HttpRequestService } from '@/core/HttpRequestService.js';
|
|
import type { LoggerService } from '@/core/LoggerService.js';
|
|
import { SensitiveMediaDetectionService, type Prediction } from '@/core/SensitiveMediaDetectionService.js';
|
|
|
|
const sendMock = vi.fn();
|
|
|
|
const DEFAULT_META = {
|
|
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
|
|
sensitiveMediaDetectionApiKey: null as string | null,
|
|
sensitiveMediaDetectionTimeout: 5000,
|
|
sensitiveMediaDetectionMaxImagesPerRequest: 4,
|
|
};
|
|
|
|
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): SensitiveMediaDetectionService {
|
|
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
|
|
const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
|
|
const loggerService = {
|
|
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
|
|
} as unknown as LoggerService;
|
|
return new SensitiveMediaDetectionService(meta, httpRequestService, loggerService);
|
|
}
|
|
|
|
function neutral(): Prediction[] {
|
|
return [{ className: 'Neutral', probability: 0.99 }];
|
|
}
|
|
|
|
function okResponse(results: unknown[]) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({ success: true, result: { results } }),
|
|
};
|
|
}
|
|
|
|
const buf = (s: string) => Buffer.from(s);
|
|
|
|
describe('SensitiveMediaDetectionService', () => {
|
|
beforeEach(() => {
|
|
sendMock.mockReset();
|
|
});
|
|
|
|
test('正常: 送信順を保った予測値配列を返す', async () => {
|
|
sendMock.mockResolvedValue(okResponse([
|
|
{ success: true, predictions: neutral() },
|
|
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
|
|
]));
|
|
const svc = makeService();
|
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
|
expect(res).toEqual([
|
|
[{ className: 'Neutral', probability: 0.99 }],
|
|
[{ className: 'Porn', probability: 0.8 }],
|
|
]);
|
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
|
expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
|
|
});
|
|
|
|
test('外部サービス: HttpRequestService を使用する', async () => {
|
|
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
|
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
|
|
|
|
await svc.detectSensitiveMany([buf('a')]);
|
|
|
|
expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
|
|
method: 'POST',
|
|
headers: {},
|
|
body: expect.any(FormData),
|
|
timeout: 5000,
|
|
}, {
|
|
throwErrorWhenResponseNotOk: false,
|
|
});
|
|
});
|
|
|
|
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
|
|
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
|
const svc = makeService();
|
|
const res = await svc.detectSensitive(buf('a'));
|
|
expect(res).toEqual(neutral());
|
|
});
|
|
|
|
test('部分失敗: 失敗パーツのみ null になる', async () => {
|
|
sendMock.mockResolvedValue(okResponse([
|
|
{ success: true, predictions: neutral() },
|
|
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
|
|
]));
|
|
const svc = makeService();
|
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
|
expect(res[0]).toEqual(neutral());
|
|
expect(res[1]).toBeNull();
|
|
});
|
|
|
|
test('非200: チャンク全件 null(例外を投げない)', async () => {
|
|
sendMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
|
|
const svc = makeService();
|
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
|
expect(res).toEqual([null, null]);
|
|
});
|
|
|
|
test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
|
|
sendMock.mockRejectedValue(new Error('network down'));
|
|
const svc = makeService();
|
|
const res = await svc.detectSensitiveMany([buf('a')]);
|
|
expect(res).toEqual([null]);
|
|
});
|
|
|
|
test('接続先未設定: HTTP を叩かず全件 null', async () => {
|
|
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
|
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
|
expect(res).toEqual([null, null]);
|
|
expect(sendMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
|
|
sendMock.mockResolvedValue(okResponse([
|
|
{ success: true, predictions: neutral() },
|
|
{ success: true, predictions: neutral() },
|
|
{ success: true, predictions: neutral() },
|
|
{ success: true, predictions: neutral() },
|
|
]));
|
|
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
|
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
|
|
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
|
|
expect(sendMock).toHaveBeenCalledTimes(3);
|
|
expect(res).toHaveLength(5);
|
|
expect(res.every(x => x != null)).toBe(true);
|
|
});
|
|
|
|
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
|
|
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
|
|
|
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
|
|
await withKey.detectSensitiveMany([buf('a')]);
|
|
const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
|
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
|
|
|
|
sendMock.mockClear();
|
|
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
|
const withoutKey = makeService();
|
|
await withoutKey.detectSensitiveMany([buf('a')]);
|
|
const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
|
expect(withoutKeyHeaders.Authorization).toBeUndefined();
|
|
});
|
|
});
|