Raiju/tests/ai-labels-smoke.php
2026-10-25 06:05:42 +03:00

107 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
// AI disclosure labels: storage, sync along file operations, detection and markup.
$root = dirname(__DIR__);
$tmp = sys_get_temp_dir() . '/raiju-ai-labels-' . bin2hex(random_bytes(4));
mkdir($tmp, 0755, true);
define('RAIJU_MEDIA_META_PATH', $tmp . '/media-meta.json');
define('SETTINGS_PATH', $tmp . '/settings.json');
require_once $root . '/includes/ai-labels.php';
function aiLabelAssert(bool $condition, string $message): void {
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
try {
// Public sources map to media type and relative name
aiLabelAssert(raijuMediaRefFromSrc('/assets/images/a.webp') === ['type' => 'image', 'name' => 'a.webp'], 'root-relative image path');
aiLabelAssert(raijuMediaRefFromSrc('../assets/images/team/b.jpg?v=3') === ['type' => 'image', 'name' => 'team/b.jpg'], 'relative path with folder and query');
aiLabelAssert(raijuMediaRefFromSrc('https://example.org/site/assets/videos/c.mp4') === ['type' => 'video', 'name' => 'c.mp4'], 'absolute video URL');
aiLabelAssert(raijuMediaRefFromSrc('/assets/images/../admin/x.php') === null, 'path traversal must be rejected');
aiLabelAssert(raijuMediaRefFromSrc('/css/style.css') === null, 'non-media sources carry no label');
// Set, read and remove
aiLabelAssert(raijuMediaAiLabel('/assets/images/a.webp') === '', 'unlabelled file');
aiLabelAssert(raijuSetMediaAiLabel('image', 'a.webp', 'generated'), 'label must be stored');
aiLabelAssert(raijuMediaAiLabel('assets/images/a.webp') === 'generated', 'stored label must be read back');
aiLabelAssert(raijuSetMediaAiLabel('image', 'a.webp', 'invalid') && raijuMediaAiLabel('/assets/images/a.webp') === '', 'unknown kinds remove the label');
aiLabelAssert(raijuSetMediaAiLabel('image', 'a.webp', ''), 'removing a missing label must succeed');
// Rename/move, trash, restore, permanent delete
raijuSetMediaAiLabel('image', 'b.webp', 'modified');
aiLabelAssert(raijuMoveMediaMeta('image', 'b.webp', 'image', 'fotos/b.webp') && raijuMediaAiLabelFor('image', 'fotos/b.webp') === 'modified', 'label must follow a move');
aiLabelAssert(raijuMediaAiLabelFor('image', 'b.webp') === '', 'old name must no longer carry the label');
raijuMoveMediaMeta('image', 'fotos/b.webp', 'image-trash', 'fotos/b.webp');
aiLabelAssert(raijuMediaAiLabelFor('image-trash', 'fotos/b.webp') === 'modified', 'label must go to the trash namespace');
raijuMoveMediaMeta('image-trash', 'fotos/b.webp', 'image', 'fotos/b-1.webp');
aiLabelAssert(raijuMediaAiLabelFor('image', 'fotos/b-1.webp') === 'modified', 'label must come back on restore');
raijuMoveMediaMeta('image', 'fotos/b-1.webp', 'image-trash', 'fotos/b-1.webp');
aiLabelAssert(raijuForgetMediaMeta('image-trash', 'fotos/b-1.webp') && raijuMediaAiLabelFor('image-trash', 'fotos/b-1.webp') === '', 'permanent delete must forget the label');
$stored = json_decode((string)file_get_contents(RAIJU_MEDIA_META_PATH), true);
aiLabelAssert($stored === [], 'empty namespaces must be pruned');
// Detection from embedded provenance (IPTC digital source type)
$generated = $tmp . '/generated.jpg';
file_put_contents($generated, "xmp Iptc4xmpExt:DigitalSourceType=\"http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia\"");
$modified = $tmp . '/modified.png';
file_put_contents($modified, "c2pa digitalSourceType http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia");
$plain = $tmp . '/plain.webp';
file_put_contents($plain, 'RIFF....WEBPVP8 ');
aiLabelAssert(raijuDetectAiLabel($generated) === 'generated', 'trained algorithmic media must be detected as generated');
aiLabelAssert(raijuDetectAiLabel($modified) === 'modified', 'composites must be detected as modified');
aiLabelAssert(raijuDetectAiLabel($plain) === '', 'files without provenance carry no label');
// Markup: English artwork by default, alt text in the page language
$GLOBALS['basePath'] = '../';
raijuSetMediaAiLabel('image', 'c.webp', 'generated');
aiLabelAssert(raijuAiLabelArtworkMode() === 'en', 'English artwork is the default');
$de = raijuAiLabelHtml('/assets/images/c.webp', 'de');
aiLabelAssert(str_contains($de, 'class="rj-ai-label rj-ai-label--generated"'), 'label wrapper with kind modifier');
aiLabelAssert(str_contains($de, 'src="../css/ai-labels/en-generated.svg"') && str_contains($de, 'alt="KI-generiert"'), 'English artwork with a German alt text on German pages');
$fr = raijuAiLabelHtml('/assets/images/c.webp', 'fr');
aiLabelAssert(str_contains($fr, 'en-generated.svg') && str_contains($fr, 'alt="Généré par IA"'), 'English artwork with a localized alt text');
// Optional German artwork on German pages (Settings → Language → AI labels on images)
file_put_contents(SETTINGS_PATH, json_encode(['general' => ['aiLabelArtwork' => 'page']]));
aiLabelAssert(raijuAiLabelArtworkMode(true) === 'page', 'the page-language artwork can be selected');
aiLabelAssert(str_contains(raijuAiLabelHtml('/assets/images/c.webp', 'de'), 'src="../css/ai-labels/de-generated.svg"'), 'German artwork on German pages when selected');
aiLabelAssert(str_contains(raijuAiLabelHtml('/assets/images/c.webp', 'fr'), 'en-generated.svg'), 'other languages keep the English artwork');
file_put_contents(SETTINGS_PATH, json_encode(['general' => ['aiLabelArtwork' => 'unknown']]));
aiLabelAssert(raijuAiLabelArtworkMode(true) === 'en', 'unknown artwork modes fall back to English');
aiLabelAssert(raijuAiLabelHtml('/assets/images/unlabelled.webp', 'de') === '', 'unlabelled images render no label');
aiLabelAssert(raijuAiLabelsRendered(), 'rendered labels must be tracked for the touch script');
$framed = raijuAiLabelAttach('<img src="/assets/images/c.webp" alt="">', '/assets/images/c.webp', true);
aiLabelAssert(str_starts_with($framed, '<span class="rj-ai-media"><img') && str_ends_with($framed, '</span></span>'), 'framed image and label');
aiLabelAssert(raijuAiLabelFrame('<img src="x.webp" alt="">') === '<img src="x.webp" alt="">', 'unlabelled markup stays unframed');
// Editor data: labelled files and label templates, without marking labels as rendered
unset($GLOBALS['raijuAiLabelsRendered']);
raijuSetMediaAiLabel('video', 'clip.mp4', 'modified');
$editorJson = json_encode(raijuAiLabelEditorConfig('de'));
$editor = json_decode((string)$editorJson, true);
aiLabelAssert(($editor['files']['image']['c.webp'] ?? '') === 'generated' && ($editor['files']['video']['clip.mp4'] ?? '') === 'modified', 'editor data lists labelled files');
aiLabelAssert(str_contains($editor['markup']['generated'] ?? '', 'en-generated.svg') && str_contains($editor['markup']['modified'] ?? '', 'rj-ai-label--modified'), 'editor data carries the label markup per kind');
aiLabelAssert(!raijuAiLabelsRendered(), 'label templates for the editor do not count as rendered labels');
raijuSetMediaAiLabel('image', 'c.webp', '');
raijuSetMediaAiLabel('video', 'clip.mp4', '');
aiLabelAssert(str_starts_with((string)json_encode(raijuAiLabelEditorConfig('de')), '{"files":{}'), 'no labelled files encode as an object');
// en-ai is a round "AI" badge that ships for later use (e.g. AI-generated texts)
foreach (['de-generated', 'de-modified', 'en-generated', 'en-modified', 'en-ai'] as $artwork) {
aiLabelAssert(is_file($root . '/css/ai-labels/' . $artwork . '.svg'), "artwork {$artwork} must ship with the core");
}
} finally {
array_map('unlink', glob($tmp . '/*') ?: []);
array_map('unlink', glob($tmp . '/.*.lock') ?: []);
@unlink(RAIJU_MEDIA_META_PATH . '.lock');
@rmdir($tmp);
}
echo "AI label smoke test passed.\n";