Files
Toju/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts
T
2026-07-14 00:41:05 +02:00

116 lines
4.0 KiB
TypeScript

import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import sharp from 'sharp';
import {
describe,
expect,
it
} from 'vitest';
import {
ADAPTIVE_FOREGROUND_ICON_RATIO,
BRAND_LAUNCHER_BACKGROUND_COLOR,
findMissingLauncherResources,
findStockCapacitorResources,
isBrandLauncherBackgroundColor,
NOTIFICATION_STATUS_ICON_NAME,
readAdaptiveIconBackgroundColor,
REQUIRED_LAUNCHER_ICON_FILES,
REQUIRED_NOTIFICATION_ICON_FILES,
REQUIRED_SPLASH_FILES,
resolveIconPixelSize,
SPLASH_ICON_RATIO
} from './mobile-android-launcher-icon.rules';
const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res');
function sha256OfResource(resRelativePath: string): string {
const bytes = readFileSync(resolve(RES_DIR, resRelativePath));
return createHash('sha256').update(bytes)
.digest('hex');
}
describe('mobile-android-launcher-icon.rules', () => {
const allRequired = [
...REQUIRED_LAUNCHER_ICON_FILES,
...REQUIRED_SPLASH_FILES,
...REQUIRED_NOTIFICATION_ICON_FILES
];
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5);
expect(resolveIconPixelSize(432)).toBe(264);
expect(SPLASH_ICON_RATIO).toBeLessThan(0.4);
});
it('ships a launcher icon and splash for every required density', () => {
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
});
it('ships a notification status-bar icon for every density', () => {
expect(findMissingLauncherResources(presentFiles, REQUIRED_NOTIFICATION_ICON_FILES)).toEqual([]);
});
it('references the notification status icon from the Capacitor config', () => {
const capacitorConfig = readFileSync(resolve(process.cwd(), 'capacitor.config.ts'), 'utf8');
expect(capacitorConfig).toContain(`smallIcon: '${NOTIFICATION_STATUS_ICON_NAME}'`);
});
it('renders the notification status icon as an alpha-only white glyph', async () => {
const iconPath = resolve(RES_DIR, 'drawable-xxxhdpi/ic_stat_metoyou.png');
const { data, info } = await sharp(iconPath).ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let opaquePixels = 0;
let transparentPixels = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
const alpha = data[offset + 3];
if (alpha > 224) {
opaquePixels += 1;
expect(data[offset]).toBeGreaterThan(224);
expect(data[offset + 1]).toBeGreaterThan(224);
expect(data[offset + 2]).toBeGreaterThan(224);
} else if (alpha < 32) {
transparentPixels += 1;
}
}
expect(opaquePixels).toBeGreaterThan(0);
expect(transparentPixels).toBeGreaterThan(0);
});
it('replaces every stock Capacitor placeholder with the brand asset', () => {
const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)]));
expect(findStockCapacitorResources(hashByFile)).toEqual([]);
});
it('uses the brand purple as the adaptive-icon background, not stock white', () => {
const valuesXml = readFileSync(resolve(RES_DIR, 'values/ic_launcher_background.xml'), 'utf8');
const color = readAdaptiveIconBackgroundColor(valuesXml);
expect(color).not.toBe('#FFFFFF');
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
});
it('insets the adaptive foreground so launcher masks do not clip the cat face', async () => {
const foregroundPath = resolve(RES_DIR, 'mipmap-xxxhdpi/ic_launcher_foreground.png');
const { data, info } = await sharp(foregroundPath).ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels;
const topCenterAlpha = data[topCenterOffset + 3];
expect(topCenterAlpha).toBeLessThan(32);
});
});