import {afterEach, describe, test, expect} from 'vitest';
import {toGraphemes} from '../util/graphemes.ts';
import {type PositionedIcon, type Box, type Shaping, applyTextFit, shapeIcon, fitIconToText, shapeText, WritingMode} from './shaping.ts';
import {ImagePosition} from '../render/image_atlas.ts';
import {type StyleImage, TextFit} from '../style/style_image.ts';
import {Formatted} from '@maplibre/maplibre-gl-style-spec';
import {verticalizedCharacterMap} from '../util/verticalize_punctuation.ts';
import {rtlWorkerPlugin} from '../source/rtl_text_plugin_worker.ts';
import type {StyleGlyph} from '../style/style_glyph.ts';

describe('applyTextFit', () => {

    describe('applyTextFitHorizontal', () => {
        // This set of tests against applyTextFit starts with a 100x20 image with a 5,5,95,15 content box
        // that has been scaled to 4x4... resulting in a 14x14 image.
        const left = 0;
        const top = 0;
        const right = 14;
        const bottom = 14;
        const rectangle = {x: 0, y: 0, w: 100, h: 20};
        const content: [number, number, number, number] = [5, 5, 95, 15];

        test('applyTextFit: not specified', async () => {
            // No change should happen
            const styleImage: StyleImage = {
                pixelRatio: 1,
                version: 1,
                sdf: false,
                content,
                data: undefined!};
            const shapedIcon: PositionedIcon = {
                left,
                top,
                right,
                bottom,
                image: new ImagePosition(rectangle, styleImage),
            };
            const result: Box = applyTextFit(shapedIcon);
            expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 14});
        });

        test('applyTextFit: stretchOrShrink', async () => {
            // No change should happen
            const styleImage: StyleImage = {
                pixelRatio: 1,
                version: 1,
                sdf: false,
                content,
                textFitWidth: TextFit.stretchOrShrink,
                textFitHeight: TextFit.stretchOrShrink,
                data: undefined!};
            const shapedIcon: PositionedIcon = {
                left,
                top,
                right,
                bottom,
                image: new ImagePosition(rectangle, styleImage),
            };
            const result: Box = applyTextFit(shapedIcon);
            expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 14});
        });

        test('applyTextFit: stretchOnly, proportional', async () => {
            // Since textFitWidth is stretchOnly, it should be returned to
            // the aspect ratio of the content rectangle (9:1) aspect ratio so 126x14.
            const styleImage: StyleImage = {
                pixelRatio: 1,
                version: 1,
                sdf: false,
                content,
                textFitWidth: TextFit.stretchOnly,
                textFitHeight: TextFit.proportional,
                data: undefined!};
            const shapedIcon: PositionedIcon = {
                left,
                top,
                right,
                bottom,
                image: new ImagePosition(rectangle, styleImage),
            };
            const result: Box = applyTextFit(shapedIcon);
            expect(result).toEqual({x1: 0, y1: 0, x2: 126, y2: 14});
        });
    });

    describe('applyTextFitVertical', () => {
        // This set of tests against applyTextFit starts with a 20x100 image with a 5,5,15,95 content box
        // that has been scaled to 4x4... resulting in a 14x14 image.
        const left = 0;
        const top = 0;
        const right = 14;
        const bottom = 14;
        const rectangle = {x: 0, y: 0, w: 20, h: 100};
        const content: [number, number, number, number] = [5, 5, 15, 95];

        test('applyTextFit: proportional, stretchOnly', async () => {
            // Since the rectangle is wider than tall, when it matches based on width (because that is proportional),
            // then the height will stretch to match the content so we also get a 14x14 image.
            const styleImage: StyleImage = {
                pixelRatio: 1,
                version: 1,
                sdf: false,
                content,
                textFitWidth: TextFit.proportional,
                textFitHeight: TextFit.stretchOnly,
                data: undefined!};
            const shapedIcon: PositionedIcon = {
                left,
                top,
                right,
                bottom,
                image: new ImagePosition(rectangle, styleImage),
            };
            const result: Box = applyTextFit(shapedIcon);
            expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 126});
        });
    });
});

describe('shapeIcon', () => {
    const imagePosition = new ImagePosition({x: 0, y: 0, w: 22, h: 22}, {pixelRatio: 1, version: 1} as StyleImage);
    const image = Object.freeze({
        content: undefined,
        stretchX: undefined,
        stretchY: undefined,
        paddedRect: Object.freeze({x: 0, y: 0, w: 22, h: 22}),
        pixelRatio: 1,
        version: 1,
        needsFirstWebGLRender: false
    });

    test('text-anchor: center', () => {
        expect(shapeIcon(imagePosition, [0, 0], 'center')).toEqual({
            top: -10,
            bottom: 10,
            left: -10,
            right: 10,
            image
        });

        expect(shapeIcon(imagePosition, [4, 7], 'center')).toEqual({
            top: -3,
            bottom: 17,
            left: -6,
            right: 14,
            image
        });

    });

    test('text-anchor: left', () => {
        expect(shapeIcon(imagePosition, [0, 0], 'left')).toEqual({
            top: -10,
            bottom: 10,
            left: 0,
            right: 20,
            image
        });

        expect(shapeIcon(imagePosition, [4, 7], 'left')).toEqual({
            top: -3,
            bottom: 17,
            left: 4,
            right: 24,
            image
        });

    });

    test('text-anchor: bottom-right', () => {
        expect(shapeIcon(imagePosition, [0, 0], 'bottom-right')).toEqual({
            top: -20,
            bottom: 0,
            left: -20,
            right: 0,
            image
        });

        expect(shapeIcon(imagePosition, [4, 7], 'bottom-right')).toEqual({
            top: -13,
            bottom: 7,
            left: -16,
            right: 4,
            image
        });

    });

});

describe('fitIconToText', () => {
    const glyphSize = 24;
    const shapedIcon = Object.freeze({
        top: -10,
        bottom: 10,
        left: -10,
        right: 10,
        collisionPadding: undefined,
        image: Object.freeze({
            pixelRatio: 1,
            displaySize: [20, 20],
            paddedRect: Object.freeze({x: 0, y: 0, w: 22, h: 22})
        })
    }) as PositionedIcon;

    const shapedText = Object.freeze({
        top: -10,
        bottom: 30,
        left: -60,
        right: 20
    }) as Shaping;

    test('icon-text-fit: width', () => {
        expect(
            fitIconToText(shapedIcon, shapedText, 'width', [0, 0, 0, 0], [0, 0], 24 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: 0,
            right: 20,
            bottom: 20,
            left: -60
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'width', [0, 0, 0, 0], [3, 7], 24 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: 7,
            right: 23,
            bottom: 27,
            left: -57
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'width', [0, 0, 0, 0], [0, 0], 12 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -5,
            right: 10,
            bottom: 15,
            left: -30
        });

        // Ignores padding for top/bottom, since the icon is only stretched to the text's width but not height
        expect(
            fitIconToText(shapedIcon, shapedText, 'width', [5, 10, 5, 10], [0, 0], 12 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -5,
            right: 20,
            bottom: 15,
            left: -40
        });

    });

    test('icon-text-fit: height', () => {
        expect(
            fitIconToText(shapedIcon, shapedText, 'height', [0, 0, 0, 0], [0, 0], 24 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -10,
            right: -10,
            bottom: 30,
            left: -30
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'height', [0, 0, 0, 0], [3, 7], 24 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -3,
            right: -7,
            bottom: 37,
            left: -27
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'height', [0, 0, 0, 0], [0, 0], 12 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -5,
            right: 0,
            bottom: 15,
            left: -20
        });

        // Ignores padding for left/right, since the icon is only stretched to the text's height but not width
        expect(
            fitIconToText(shapedIcon, shapedText, 'height', [5, 10, 5, 10], [0, 0], 12 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -10,
            right: 0,
            bottom: 20,
            left: -20
        });

    });

    test('icon-text-fit: both', () => {
        expect(
            fitIconToText(shapedIcon, shapedText, 'both', [0, 0, 0, 0], [0, 0], 24 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -10,
            right: 20,
            bottom: 30,
            left: -60
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'both', [0, 0, 0, 0], [3, 7], 24 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -3,
            right: 23,
            bottom: 37,
            left: -57
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'both', [0, 0, 0, 0], [0, 0], 12 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -5,
            right: 10,
            bottom: 15,
            left: -30
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'both', [5, 10, 5, 10], [0, 0], 12 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -10,
            right: 20,
            bottom: 20,
            left: -40
        });

        expect(
            fitIconToText(shapedIcon, shapedText, 'both', [0, 5, 10, 15], [0, 0], 12 / glyphSize)
        ).toEqual({
            image: shapedIcon.image,
            collisionPadding: undefined,
            top: -5,
            right: 15,
            bottom: 25,
            left: -45
        });

    });

});

describe('shapeText vertical glyph orientation', () => {
    const fontStack = 'Test';

    type ShapeLineLabelOptions = {
        writingMode?: WritingMode.horizontal | WritingMode.vertical;
        allowVerticalPlacement?: boolean;
    };

    function createStubGlyph(codePoint: number): StyleGlyph {
        return {
            id: codePoint,
            bitmap: null,
            metrics: {width: 10, height: 18, left: 1, top: -8, advance: 12}
        };
    }

    /** Keyed by grapheme cluster, which is what layout looks glyphs up by. */
    function createStubGlyphMap(text: string): Record<string, StyleGlyph> {
        const glyphs: Record<string, StyleGlyph> = {};
        const verticalizedChars = Object.entries(verticalizedCharacterMap)
            .filter(([char]) => text.includes(char))
            .map(([, verticalizedChar]) => verticalizedChar);

        for (const grapheme of [...toGraphemes(text), ...verticalizedChars]) {
            glyphs[grapheme] = createStubGlyph(grapheme.codePointAt(0));
        }
        return glyphs;
    }

    function shapeLineLabel(text: string, {writingMode = WritingMode.vertical, allowVerticalPlacement = false}: ShapeLineLabelOptions = {}): Shaping | false {
        const glyphs = createStubGlyphMap(text);
        return shapeText(Formatted.fromString(text), {[fontStack]: glyphs}, {}, {}, fontStack, Infinity, 24, 'center', 'center', 0, [0, 0], writingMode, allowVerticalPlacement, 24, 24);
    }

    function getGlyphOrientations(shaping: Shaping | false): Array<[string, string]> {
        expect(shaping).toBeTruthy();
        return (shaping as Shaping).positionedLines.flatMap(line => line.positionedGlyphs.map(
            (glyph): [string, string] => [glyph.grapheme, glyph.vertical ? 'upright' : 'along-line']));
    }

    test('draws digits between CJK characters upright', () => {
        // The label reported in https://github.com/maplibre/maplibre-gl-js/issues/5404
        const shapedLineLabel = shapeLineLabel('반포대로21길');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['반', 'upright'],
            ['포', 'upright'],
            ['대', 'upright'],
            ['로', 'upright'],
            ['2', 'upright'],
            ['1', 'upright'],
            ['길', 'upright'],
        ]);
    });

    test('draws a trailing digit run upright', () => {
        const shapedLineLabel = shapeLineLabel('身什戰33');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['身', 'upright'],
            ['什', 'upright'],
            ['戰', 'upright'],
            ['3', 'upright'],
            ['3', 'upright'],
        ]);
    });

    test('draws a digit run upright regardless of its length', () => {
        const shapedLineLabel = shapeLineLabel('国道1234号');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['1', 'upright'],
            ['2', 'upright'],
            ['3', 'upright'],
            ['4', 'upright'],
            ['号', 'upright'],
        ]);
    });

    test('draws a whitespace-separated digit upright', () => {
        const shapedLineLabel = shapeLineLabel('身什戰 1');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['身', 'upright'],
            ['什', 'upright'],
            ['戰', 'upright'],
            [' ', 'along-line'],
            ['1', 'upright'],
        ]);
    });

    test('draws a single uppercase letter between CJK characters upright', () => {
        const shapedLineLabel = shapeLineLabel('国道A号');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['A', 'upright'],
            ['号', 'upright'],
        ]);
    });

    test('draws a decomposed Latin letter and its combining mark as one glyph, so the mark cannot drift off its base', () => {
        const shapedLineLabel = shapeLineLabel('国道e\u0301号');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['e\u0301', 'along-line'],
            ['号', 'upright'],
        ]);
    });

    test('rotates a single lowercase letter mixed with CJK', () => {
        const shapedLineLabel = shapeLineLabel('国道α号');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['α', 'along-line'],
            ['号', 'upright'],
        ]);
    });

    test('draws short uppercase codes of non-Latin scripts upright', () => {
        const shapedLineLabel = shapeLineLabel('国道\u041C1号');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['\u041C', 'upright'], // Cyrillic capital М
            ['1', 'upright'],
            ['号', 'upright'],
        ]);
    });

    test('rotates complex-shaping script characters mixed with CJK', () => {
        const shapedLineLabel = shapeLineLabel('国道ب号');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['ب', 'along-line'],
            ['号', 'upright'],
        ]);
    });

    test('rotates the prolonged sound mark so it reads as a vertical stroke', () => {
        const shapedLineLabel = shapeLineLabel('札幌タワー');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['札', 'upright'],
            ['幌', 'upright'],
            ['タ', 'upright'],
            ['ワ', 'upright'],
            ['ー', 'along-line'],
        ]);
    });

    test('rotates a wave dash between upright digits', () => {
        const shapedLineLabel = shapeLineLabel('身什戰1〜2');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['身', 'upright'],
            ['什', 'upright'],
            ['戰', 'upright'],
            ['1', 'upright'],
            ['〜', 'along-line'],
            ['2', 'upright'],
        ]);
    });

    test('rotates lowercase Latin words', () => {
        const shapedLineLabel = shapeLineLabel('two 身什戰');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['t', 'along-line'],
            ['w', 'along-line'],
            ['o', 'along-line'],
            [' ', 'along-line'],
            ['身', 'upright'],
            ['什', 'upright'],
            ['戰', 'upright'],
        ]);
    });

    test('draws mixed alphanumeric codes upright', () => {
        const shapedLineLabel = shapeLineLabel('身什戰A1');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['身', 'upright'],
            ['什', 'upright'],
            ['戰', 'upright'],
            ['A', 'upright'],
            ['1', 'upright'],
        ]);
    });

    test('rotates Latin words directly adjoining CJK characters', () => {
        const shapedLineLabel = shapeLineLabel('銀座Ginza通り');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['銀', 'upright'],
            ['座', 'upright'],
            ['G', 'along-line'],
            ['i', 'along-line'],
            ['n', 'along-line'],
            ['z', 'along-line'],
            ['a', 'along-line'],
            ['通', 'upright'],
            ['り', 'upright'],
        ]);
    });

    test('rotates long uppercase words of dual names', () => {
        const shapedLineLabel = shapeLineLabel('ヴィラ ISHIKAWA');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['ヴ', 'upright'],
            ['ィ', 'upright'],
            ['ラ', 'upright'],
            [' ', 'along-line'],
            ['I', 'along-line'],
            ['S', 'along-line'],
            ['H', 'along-line'],
            ['I', 'along-line'],
            ['K', 'along-line'],
            ['A', 'along-line'],
            ['W', 'along-line'],
            ['A', 'along-line'],
        ]);
    });

    test('draws short uppercase codes upright', () => {
        const shapedLineLabel = shapeLineLabel('JR山手線');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['J', 'upright'],
            ['R', 'upright'],
            ['山', 'upright'],
            ['手', 'upright'],
            ['線', 'upright'],
        ]);
    });

    test('replaces a symbol between upright digits with its vertical form', () => {
        const shapedLineLabel = shapeLineLabel('国道1-2号');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['1', 'upright'],
            ['︲', 'upright'], // “-” replaced by U+FE32 presentation form for vertical en dash
            ['2', 'upright'],
            ['号', 'upright'],
        ]);
    });

    test('keeps the horizontal form of symbols in runs that stay rotated', () => {
        const shapedLineLabel = shapeLineLabel('国道3.5km');
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['国', 'upright'],
            ['道', 'upright'],
            ['3', 'along-line'],
            ['.', 'along-line'],
            ['5', 'along-line'],
            ['k', 'along-line'],
            ['m', 'along-line'],
        ]);
    });

    test('keeps orientations aligned after a missing glyph', () => {
        // “w” has no glyph, so it produces no positioned glyph; the characters
        // after it must still get their own orientation, not their neighbor's.
        const text = 'what 国21号';
        const availableGlyphText = [...text]
            .filter(char => char !== 'w')
            .join('');
        const glyphs = createStubGlyphMap(availableGlyphText);
        const shapedLineLabel = shapeText(Formatted.fromString(text), {[fontStack]: glyphs}, {}, {}, fontStack, Infinity, 24, 'center', 'center', 0, [0, 0], WritingMode.vertical, false, 24, 24);
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['h', 'along-line'],
            ['a', 'along-line'],
            ['t', 'along-line'],
            [' ', 'along-line'],
            ['国', 'upright'],
            ['2', 'upright'],
            ['1', 'upright'],
            ['号', 'upright'],
        ]);
    });

    test('does not mark glyphs upright in horizontal writing mode', () => {
        const shapedLineLabel = shapeLineLabel('身什戰33', {writingMode: WritingMode.horizontal});
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['身', 'along-line'],
            ['什', 'along-line'],
            ['戰', 'along-line'],
            ['3', 'along-line'],
            ['3', 'along-line'],
        ]);
    });

    test('draws every non-whitespace glyph upright when vertical placement is allowed', () => {
        const shapedLineLabel = shapeLineLabel('two 身什戰', {allowVerticalPlacement: true});
        const orientations = getGlyphOrientations(shapedLineLabel);

        expect(orientations).toEqual([
            ['t', 'upright'],
            ['w', 'upright'],
            ['o', 'upright'],
            [' ', 'along-line'],
            ['身', 'upright'],
            ['什', 'upright'],
            ['戰', 'upright'],
        ]);
    });
});

describe('shapeText with a right-to-left text plugin', () => {
    const metrics = {width: 10, height: 10, left: 0, top: -8, advance: 10};

    function stubReversingPlugin() {
        rtlWorkerPlugin.processBidirectionalText = (text: string, lineBreaks: number[]) => {
            const lines: string[] = [];
            let start = 0;
            for (const at of [...lineBreaks, text.length]) {
                if (at > start) lines.push([...text.slice(start, at)].reverse().join(''));
                start = at;
            }
            return lines;
        };
        rtlWorkerPlugin.processStyledBidirectionalText = null;
    }

    afterEach(() => {
        rtlWorkerPlugin.processBidirectionalText = null;
        rtlWorkerPlugin.processStyledBidirectionalText = null;
    });

    /** A glyph for every grapheme cluster of `text`, and for every codepoint, as a tile asks for. */
    function glyphsFor(text: string): Record<string, Record<string, StyleGlyph>> {
        const glyphs: Record<string, StyleGlyph> = {};
        for (const grapheme of toGraphemes(text)) {
            glyphs[grapheme] = {id: grapheme.codePointAt(0), metrics} as StyleGlyph;
            for (const char of grapheme) {
                glyphs[char] = {id: char.codePointAt(0), metrics} as StyleGlyph;
            }
        }
        return {Test: glyphs};
    }

    function shape(text: string, glyphs = glyphsFor(text)): Shaping | false {
        return shapeText(
            Formatted.fromString(text), glyphs, {}, {}, 'Test',
            Infinity, 24, 'center', 'center', 0, [0, 0], WritingMode.horizontal, false, 24, 24,
        );
    }

    /** The grapheme cluster each positioned glyph draws, in the order they are drawn. */
    function drawn(shaping: Shaping | false): string[] {
        expect(shaping).toBeTruthy();
        return (shaping as Shaping).positionedLines.flatMap(
            line => line.positionedGlyphs.map(glyph => glyph.grapheme));
    }

    const SHIN_SHEVA_DOT = 'שְׁ';
    const DALET_TSERE = 'דֵ';
    const RESH = 'ר';
    const VAV_HOLAM = 'וֹ';
    const TAV = 'ת';
    const text = SHIN_SHEVA_DOT + DALET_TSERE + RESH + VAV_HOLAM + TAV;

    test('reverses the letters but keeps each one with the marks written on it, in writing order', () => {
        stubReversingPlugin();

        expect(drawn(shape(text))).toEqual([TAV, VAV_HOLAM, RESH, DALET_TSERE, SHIN_SHEVA_DOT]);
    });

    test('draws only clusters the tile asked for a glyph for', () => {
        stubReversingPlugin();

        const requested = new Set(Object.keys(glyphsFor(text).Test));
        for (const grapheme of drawn(shape(text))) {
            expect(requested).toContain(grapheme);
        }
    });

    test('falls back to the same codepoints in the same order when no glyph covers the cluster', () => {
        stubReversingPlugin();

        const codepointsOnly: Record<string, StyleGlyph> = {};
        for (const char of text) {
            codepointsOnly[char] = {id: char.codePointAt(0), metrics} as StyleGlyph;
        }

        const asClusters = drawn(shape(text)).join('');
        const asCodepoints = drawn(shape(text, {Test: codepointsOnly}));

        expect(asCodepoints.join('')).toBe(asClusters);
        expect(asCodepoints).toHaveLength([...text].length);
    });
});
