116 lines
4.0 KiB
TypeScript
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);
|
|
});
|
|
});
|