from fontTools.misc.fixedTools import floatToFixedToFloat
from fontTools.misc.roundTools import noRound
from fontTools.misc.testTools import stripVariableItemsFromTTX
from fontTools.misc.textTools import Tag
from fontTools import ttLib
from fontTools import designspaceLib
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
from fontTools.ttLib.tables import _f_v_a_r, _g_l_y_f
from fontTools.ttLib.tables import otTables
from fontTools.ttLib.tables.TupleVariation import TupleVariation
from fontTools import varLib
from fontTools.varLib import instancer
from fontTools.varLib.mvar import MVAR_ENTRIES
from fontTools.varLib import builder
from fontTools.varLib import featureVars
from fontTools.varLib import models
import collections
from copy import deepcopy
from io import BytesIO, StringIO
import logging
import os
import re
from types import SimpleNamespace
import pytest


# see Tests/varLib/instancer/conftest.py for "varfont" fixture definition

TESTDATA = os.path.join(os.path.dirname(__file__), "data")


@pytest.fixture(params=[True, False], ids=["optimize", "no-optimize"])
def optimize(request):
    return request.param


@pytest.fixture
def fvarAxes():
    wght = _f_v_a_r.Axis()
    wght.axisTag = Tag("wght")
    wght.minValue = 100
    wght.defaultValue = 400
    wght.maxValue = 900
    wdth = _f_v_a_r.Axis()
    wdth.axisTag = Tag("wdth")
    wdth.minValue = 70
    wdth.defaultValue = 100
    wdth.maxValue = 100
    return [wght, wdth]


def _get_coordinates(varfont, glyphname):
    # converts GlyphCoordinates to a list of (x, y) tuples, so that pytest's
    # assert will give us a nicer diff
    return list(
        varfont["glyf"]._getCoordinatesAndControls(
            glyphname,
            varfont["hmtx"].metrics,
            varfont["vmtx"].metrics,
            # the tests expect float coordinates
            round=noRound,
        )[0]
    )


class InstantiateCFF2Test(object):
    @pytest.mark.parametrize(
        "location, expected",
        [
            (
                {},
                [
                    44,
                    256,
                    6,
                    -29,
                    2,
                    "blend",
                    "rmoveto",
                    239,
                    35,
                    -239,
                    44,
                    90,
                    -44,
                    3,
                    "blend",
                    "hlineto",
                ],
            ),
            ({"wght": 0}, [44, 256, "rmoveto", 239, 35, -239, "hlineto"]),
            ({"wght": 0.5}, [47, 242, "rmoveto", 261, 80, -261, "hlineto"]),
            ({"wght": 1}, [50, 227, "rmoveto", 283, 125, -283, "hlineto"]),
        ],
    )
    def test_pin_and_drop_axis(self, varfont, location, expected):

        varfont = ttLib.TTFont()
        varfont.importXML(os.path.join(TESTDATA, "CFF2Instancer-VF-1.ttx"))

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateCFF2(varfont, location)
        instancer.instantiateHVAR(varfont, location)

        program = varfont["CFF2"].cff.topDictIndex[0].CharStrings.values()[1].program
        assert program == expected

    @pytest.mark.parametrize(
        "source_ttx, expected_ttx",
        [
            ("CFF2Instancer-VF-1.ttx", "CFF2Instancer-VF-1-instance-400.ttx"),
            ("CFF2Instancer-VF-2.ttx", "CFF2Instancer-VF-2-instance-400.ttx"),
            ("CFF2Instancer-VF-3.ttx", "CFF2Instancer-VF-3-instance-400.ttx"),
        ],
    )
    def test_full_instance(self, varfont, source_ttx, expected_ttx):
        varfont = ttLib.TTFont()
        varfont.importXML(os.path.join(TESTDATA, source_ttx))
        s = BytesIO()
        varfont.save(s)
        s.seek(0)
        varfont = ttLib.TTFont(s)

        instance = instancer.instantiateVariableFont(varfont, {"wght": 400})
        s = BytesIO()
        instance.save(s)
        s.seek(0)
        instance = ttLib.TTFont(s)

        s = StringIO()
        instance.saveXML(s)
        actual = stripVariableItemsFromTTX(s.getvalue())

        expected = ttLib.TTFont()
        expected.importXML(os.path.join(TESTDATA, "test_results", expected_ttx))
        s = BytesIO()
        expected.save(s)
        s.seek(0)
        expected = ttLib.TTFont(s)
        s = StringIO()
        expected.saveXML(s)
        expected = stripVariableItemsFromTTX(s.getvalue())

        assert actual == expected


class InstantiateGvarTest(object):
    @pytest.mark.parametrize("glyph_name", ["hyphen"])
    @pytest.mark.parametrize(
        "location, expected",
        [
            pytest.param(
                {"wdth": -1.0},
                {
                    "hyphen": [
                        (27, 229),
                        (27, 310),
                        (247, 310),
                        (247, 229),
                        (0, 0),
                        (274, 0),
                        (0, 536),
                        (0, 0),
                    ]
                },
                id="wdth=-1.0",
            ),
            pytest.param(
                {"wdth": -0.5},
                {
                    "hyphen": [
                        (33.5, 229),
                        (33.5, 308.5),
                        (264.5, 308.5),
                        (264.5, 229),
                        (0, 0),
                        (298, 0),
                        (0, 536),
                        (0, 0),
                    ]
                },
                id="wdth=-0.5",
            ),
            # an axis pinned at the default normalized location (0.0) means
            # the default glyf outline stays the same
            pytest.param(
                {"wdth": 0.0},
                {
                    "hyphen": [
                        (40, 229),
                        (40, 307),
                        (282, 307),
                        (282, 229),
                        (0, 0),
                        (322, 0),
                        (0, 536),
                        (0, 0),
                    ]
                },
                id="wdth=0.0",
            ),
        ],
    )
    def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected, optimize):
        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateGvar(varfont, location, optimize=optimize)

        assert _get_coordinates(varfont, glyph_name) == expected[glyph_name]

        # check that the pinned axis has been dropped from gvar
        assert not any(
            "wdth" in t.axes
            for tuples in varfont["gvar"].variations.values()
            for t in tuples
        )

    def test_full_instance(self, varfont, optimize):
        location = instancer.NormalizedAxisLimits(wght=0.0, wdth=-0.5)

        instancer.instantiateGvar(varfont, location, optimize=optimize)

        assert _get_coordinates(varfont, "hyphen") == [
            (33.5, 229),
            (33.5, 308.5),
            (264.5, 308.5),
            (264.5, 229),
            (0, 0),
            (298, 0),
            (0, 536),
            (0, 0),
        ]

        assert "gvar" not in varfont

    def test_composite_glyph_not_in_gvar(self, varfont):
        """The 'minus' glyph is a composite glyph, which references 'hyphen' as a
        component, but has no tuple variations in gvar table, so the component offset
        and the phantom points do not change; however the sidebearings and bounding box
        do change as a result of the parent glyph 'hyphen' changing.
        """
        hmtx = varfont["hmtx"]
        vmtx = varfont["vmtx"]

        hyphenCoords = _get_coordinates(varfont, "hyphen")
        assert hyphenCoords == [
            (40, 229),
            (40, 307),
            (282, 307),
            (282, 229),
            (0, 0),
            (322, 0),
            (0, 536),
            (0, 0),
        ]
        assert hmtx["hyphen"] == (322, 40)
        assert vmtx["hyphen"] == (536, 229)

        minusCoords = _get_coordinates(varfont, "minus")
        assert minusCoords == [(0, 0), (0, 0), (422, 0), (0, 536), (0, 0)]
        assert hmtx["minus"] == (422, 40)
        assert vmtx["minus"] == (536, 229)

        location = instancer.NormalizedAxisLimits(wght=-1.0, wdth=-1.0)

        instancer.instantiateGvar(varfont, location)

        # check 'hyphen' coordinates changed
        assert _get_coordinates(varfont, "hyphen") == [
            (26, 259),
            (26, 286),
            (237, 286),
            (237, 259),
            (0, 0),
            (263, 0),
            (0, 536),
            (0, 0),
        ]
        # check 'minus' coordinates (i.e. component offset and phantom points)
        # did _not_ change
        assert _get_coordinates(varfont, "minus") == minusCoords

        assert hmtx["hyphen"] == (263, 26)
        assert vmtx["hyphen"] == (536, 250)

        assert hmtx["minus"] == (422, 26)  # 'minus' left sidebearing changed
        assert vmtx["minus"] == (536, 250)  # 'minus' top sidebearing too


class InstantiateCvarTest(object):
    @pytest.mark.parametrize(
        "location, expected",
        [
            pytest.param({"wght": -1.0}, [500, -400, 150, 250], id="wght=-1.0"),
            pytest.param({"wdth": -1.0}, [500, -400, 180, 200], id="wdth=-1.0"),
            pytest.param({"wght": -0.5}, [500, -400, 165, 250], id="wght=-0.5"),
            pytest.param({"wdth": -0.3}, [500, -400, 180, 235], id="wdth=-0.3"),
        ],
    )
    def test_pin_and_drop_axis(self, varfont, location, expected):
        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateCvar(varfont, location)

        assert list(varfont["cvt "].values) == expected

        # check that the pinned axis has been dropped from cvar
        pinned_axes = location.keys()
        assert not any(
            axis in t.axes for t in varfont["cvar"].variations for axis in pinned_axes
        )

    def test_full_instance(self, varfont):
        location = instancer.NormalizedAxisLimits(wght=-0.5, wdth=-0.5)

        instancer.instantiateCvar(varfont, location)

        assert list(varfont["cvt "].values) == [500, -400, 165, 225]

        assert "cvar" not in varfont


class InstantiateMVARTest(object):
    @pytest.mark.parametrize(
        "location, expected",
        [
            pytest.param(
                {"wght": 1.0},
                {"strs": 100, "undo": -200, "unds": 150, "xhgt": 530},
                id="wght=1.0",
            ),
            pytest.param(
                {"wght": 0.5},
                {"strs": 75, "undo": -150, "unds": 100, "xhgt": 515},
                id="wght=0.5",
            ),
            pytest.param(
                {"wght": 0.0},
                {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500},
                id="wght=0.0",
            ),
            pytest.param(
                {"wdth": -1.0},
                {"strs": 20, "undo": -100, "unds": 50, "xhgt": 500},
                id="wdth=-1.0",
            ),
            pytest.param(
                {"wdth": -0.5},
                {"strs": 35, "undo": -100, "unds": 50, "xhgt": 500},
                id="wdth=-0.5",
            ),
            pytest.param(
                {"wdth": 0.0},
                {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500},
                id="wdth=0.0",
            ),
        ],
    )
    def test_pin_and_drop_axis(self, varfont, location, expected):
        mvar = varfont["MVAR"].table
        # initially we have two VarData: the first contains deltas associated with 3
        # regions: 1 with only wght, 1 with only wdth, and 1 with both wght and wdth
        assert len(mvar.VarStore.VarData) == 2
        assert mvar.VarStore.VarRegionList.RegionCount == 3
        assert mvar.VarStore.VarData[0].VarRegionCount == 3
        assert all(len(item) == 3 for item in mvar.VarStore.VarData[0].Item)
        # The second VarData has deltas associated only with 1 region (wght only).
        assert mvar.VarStore.VarData[1].VarRegionCount == 1
        assert all(len(item) == 1 for item in mvar.VarStore.VarData[1].Item)

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateMVAR(varfont, location)

        for mvar_tag, expected_value in expected.items():
            table_tag, item_name = MVAR_ENTRIES[mvar_tag]
            assert getattr(varfont[table_tag], item_name) == expected_value

        # check that regions and accompanying deltas have been dropped
        num_regions_left = len(mvar.VarStore.VarRegionList.Region)
        assert num_regions_left < 3
        assert mvar.VarStore.VarRegionList.RegionCount == num_regions_left
        assert mvar.VarStore.VarData[0].VarRegionCount == num_regions_left
        # VarData subtables have been merged
        assert len(mvar.VarStore.VarData) == 1

    @pytest.mark.parametrize(
        "location, expected, sync_vmetrics",
        [
            pytest.param(
                {"wght": 1.0, "wdth": 0.0},
                {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
                True,
                id="wght=1.0,wdth=0.0",
            ),
            pytest.param(
                {"wght": 0.0, "wdth": -1.0},
                {"strs": 20, "undo": -100, "unds": 50, "hasc": 1000},
                True,
                id="wght=0.0,wdth=-1.0",
            ),
            pytest.param(
                {"wght": 0.5, "wdth": -0.5},
                {"strs": 55, "undo": -145, "unds": 95, "hasc": 1050},
                True,
                id="wght=0.5,wdth=-0.5",
            ),
            pytest.param(
                {"wght": 1.0, "wdth": -1.0},
                {"strs": 50, "undo": -180, "unds": 130, "hasc": 1100},
                True,
                id="wght=0.5,wdth=-0.5",
            ),
            pytest.param(
                {"wght": 1.0, "wdth": 0.0},
                {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
                False,
                id="wght=1.0,wdth=0.0,no_sync_vmetrics",
            ),
        ],
    )
    def test_full_instance(self, varfont, location, sync_vmetrics, expected):
        location = instancer.NormalizedAxisLimits(location)

        # check vertical metrics are in sync before...
        if sync_vmetrics:
            assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
            assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
            assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
        else:
            # force them not to be in sync
            varfont["OS/2"].sTypoDescender -= 100
            varfont["OS/2"].sTypoLineGap += 200

        instancer.instantiateMVAR(varfont, location)

        for mvar_tag, expected_value in expected.items():
            table_tag, item_name = MVAR_ENTRIES[mvar_tag]
            assert getattr(varfont[table_tag], item_name) == expected_value

        # ... as well as after instancing, but only if they were already
        # https://github.com/fonttools/fonttools/issues/3297
        if sync_vmetrics:
            assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
            assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
            assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
        else:
            assert varfont["OS/2"].sTypoDescender != varfont["hhea"].descender
            assert varfont["OS/2"].sTypoLineGap != varfont["hhea"].lineGap

        assert "MVAR" not in varfont


class InstantiateHVARTest(object):
    # the 'expectedDeltas' below refer to the VarData item deltas for the "hyphen"
    # glyph in the PartialInstancerTest-VF.ttx test font, that are left after
    # partial instancing
    @pytest.mark.parametrize(
        "location, expectedRegions, expectedDeltas",
        [
            ({"wght": -1.0}, [{"wdth": (-1.0, -1.0, 0)}], [-59]),
            ({"wght": 0}, [{"wdth": (-1.0, -1.0, 0)}], [-48]),
            ({"wght": 1.0}, [{"wdth": (-1.0, -1.0, 0)}], [7]),
            (
                {"wdth": -1.0},
                [
                    {"wght": (-1.0, -1.0, 0.0)},
                    {"wght": (0.0, 0.6099854, 1.0)},
                    {"wght": (0.6099854, 1.0, 1.0)},
                ],
                [-11, 31, 51],
            ),
            ({"wdth": 0}, [{"wght": (0.6099854, 1.0, 1.0)}], [-4]),
        ],
    )
    def test_partial_instance(self, varfont, location, expectedRegions, expectedDeltas):
        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateHVAR(varfont, location)

        assert "HVAR" in varfont
        hvar = varfont["HVAR"].table
        varStore = hvar.VarStore

        regions = varStore.VarRegionList.Region
        fvarAxes = [a for a in varfont["fvar"].axes if a.axisTag not in location]
        regionDicts = [reg.get_support(fvarAxes) for reg in regions]
        assert len(regionDicts) == len(expectedRegions)
        for region, expectedRegion in zip(regionDicts, expectedRegions):
            assert region.keys() == expectedRegion.keys()
            for axisTag, support in region.items():
                assert support == pytest.approx(expectedRegion[axisTag])

        assert len(varStore.VarData) == 1
        assert varStore.VarData[0].ItemCount == 2

        assert hvar.AdvWidthMap is not None
        advWithMap = hvar.AdvWidthMap.mapping

        assert advWithMap[".notdef"] == advWithMap["space"]
        varIdx = advWithMap[".notdef"]
        # these glyphs have no metrics variations in the test font
        assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == (
            [0] * varStore.VarData[0].VarRegionCount
        )

        varIdx = advWithMap["hyphen"]
        assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == expectedDeltas

    def test_full_instance(self, varfont):
        location = instancer.NormalizedAxisLimits(wght=0, wdth=0)

        instancer.instantiateHVAR(varfont, location)

        assert "HVAR" not in varfont

    def test_partial_instance_keep_empty_table(self, varfont):
        # Append an additional dummy axis to fvar, for which the current HVAR table
        # in our test 'varfont' contains no variation data.
        # Instancing the other two wght and wdth axes should leave HVAR table empty,
        # to signal there are variations to the glyph's advance widths.
        fvar = varfont["fvar"]
        axis = _f_v_a_r.Axis()
        axis.axisTag = "TEST"
        fvar.axes.append(axis)

        location = instancer.NormalizedAxisLimits(wght=0, wdth=0)

        instancer.instantiateHVAR(varfont, location)

        assert "HVAR" in varfont

        varStore = varfont["HVAR"].table.VarStore

        assert varStore.VarRegionList.RegionCount == 0
        assert not varStore.VarRegionList.Region
        assert varStore.VarRegionList.RegionAxisCount == 1


class InstantiateItemVariationStoreTest(object):
    def test_VarRegion_get_support(self):
        axisOrder = ["wght", "wdth", "opsz"]
        regionAxes = {"wdth": (-1.0, -1.0, 0.0), "wght": (0.0, 1.0, 1.0)}
        region = builder.buildVarRegion(regionAxes, axisOrder)

        assert len(region.VarRegionAxis) == 3
        assert region.VarRegionAxis[2].PeakCoord == 0

        fvarAxes = [SimpleNamespace(axisTag=axisTag) for axisTag in axisOrder]

        assert region.get_support(fvarAxes) == regionAxes

    @pytest.fixture
    def varStore(self):
        return builder.buildVarStore(
            builder.buildVarRegionList(
                [
                    {"wght": (-1.0, -1.0, 0)},
                    {"wght": (0, 0.5, 1.0)},
                    {"wght": (0.5, 1.0, 1.0)},
                    {"wdth": (-1.0, -1.0, 0)},
                    {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)},
                    {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)},
                    {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)},
                ],
                ["wght", "wdth"],
            ),
            [
                builder.buildVarData([0, 1, 2], [[100, 100, 100], [100, 100, 100]]),
                builder.buildVarData(
                    [3, 4, 5, 6], [[100, 100, 100, 100], [100, 100, 100, 100]]
                ),
            ],
        )

    @pytest.mark.parametrize(
        "location, expected_deltas, num_regions",
        [
            ({"wght": 0}, [[0, 0], [0, 0]], 1),
            ({"wght": 0.25}, [[50, 50], [0, 0]], 1),
            ({"wdth": 0}, [[0, 0], [0, 0]], 3),
            ({"wdth": -0.75}, [[0, 0], [75, 75]], 3),
            ({"wght": 0, "wdth": 0}, [[0, 0], [0, 0]], 0),
            ({"wght": 0.25, "wdth": 0}, [[50, 50], [0, 0]], 0),
            ({"wght": 0, "wdth": -0.75}, [[0, 0], [75, 75]], 0),
        ],
    )
    def test_instantiate_default_deltas(
        self, varStore, fvarAxes, location, expected_deltas, num_regions
    ):
        location = instancer.NormalizedAxisLimits(location)

        defaultDeltas = instancer.instantiateItemVariationStore(
            varStore, fvarAxes, location
        )

        defaultDeltaArray = []
        for varidx, delta in sorted(defaultDeltas.items()):
            if varidx == varStore.NO_VARIATION_INDEX:
                continue
            major, minor = varidx >> 16, varidx & 0xFFFF
            if major == len(defaultDeltaArray):
                defaultDeltaArray.append([])
            assert len(defaultDeltaArray[major]) == minor
            defaultDeltaArray[major].append(delta)

        assert defaultDeltaArray == expected_deltas
        assert varStore.VarRegionList.RegionCount == num_regions


class TupleVarStoreAdapterTest(object):
    def test_instantiate(self):
        regions = [
            {"wght": (-1.0, -1.0, 0)},
            {"wght": (0.0, 1.0, 1.0)},
            {"wdth": (-1.0, -1.0, 0)},
            {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)},
            {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)},
        ]
        axisOrder = ["wght", "wdth"]
        tupleVarData = [
            [
                TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]),
                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [30, 90]),
                TupleVariation(
                    {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100]
                ),
                TupleVariation(
                    {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120]
                ),
            ],
            [
                TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]),
                TupleVariation(
                    {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55]
                ),
                TupleVariation(
                    {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75]
                ),
            ],
        ]
        adapter = instancer._TupleVarStoreAdapter(
            regions, axisOrder, tupleVarData, itemCounts=[2, 2]
        )
        location = instancer.NormalizedAxisLimits(wght=0.5)

        defaultDeltaArray = adapter.instantiate(location)

        assert defaultDeltaArray == [[15, 45], [0, 0]]
        assert adapter.regions == [{"wdth": (-1.0, -1.0, 0)}]
        assert adapter.tupleVarData == [
            [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-30, -60])],
            [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-12, 8])],
        ]

    def test_rebuildRegions(self):
        regions = [
            {"wght": (-1.0, -1.0, 0)},
            {"wght": (0.0, 1.0, 1.0)},
            {"wdth": (-1.0, -1.0, 0)},
            {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)},
            {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)},
        ]
        axisOrder = ["wght", "wdth"]
        variations = []
        for region in regions:
            variations.append(TupleVariation(region, [100]))
        tupleVarData = [variations[:3], variations[3:]]
        adapter = instancer._TupleVarStoreAdapter(
            regions, axisOrder, tupleVarData, itemCounts=[1, 1]
        )

        adapter.rebuildRegions()

        assert adapter.regions == regions

        del tupleVarData[0][2]
        tupleVarData[1][0].axes = {"wght": (-1.0, -0.5, 0)}
        tupleVarData[1][1].axes = {"wght": (0, 0.5, 1.0)}

        adapter.rebuildRegions()

        assert adapter.regions == [
            {"wght": (-1.0, -1.0, 0)},
            {"wght": (0.0, 1.0, 1.0)},
            {"wght": (-1.0, -0.5, 0)},
            {"wght": (0, 0.5, 1.0)},
        ]

    def test_roundtrip(self, fvarAxes):
        regions = [
            {"wght": (-1.0, -1.0, 0)},
            {"wght": (0, 0.5, 1.0)},
            {"wght": (0.5, 1.0, 1.0)},
            {"wdth": (-1.0, -1.0, 0)},
            {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)},
            {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)},
            {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)},
        ]
        axisOrder = [axis.axisTag for axis in fvarAxes]

        itemVarStore = builder.buildVarStore(
            builder.buildVarRegionList(regions, axisOrder),
            [
                builder.buildVarData(
                    [0, 1, 2, 4, 5, 6],
                    [[10, -20, 30, -40, 50, -60], [70, -80, 90, -100, 110, -120]],
                ),
                builder.buildVarData(
                    [3, 4, 5, 6], [[5, -15, 25, -35], [45, -55, 65, -75]]
                ),
            ],
        )

        adapter = instancer._TupleVarStoreAdapter.fromItemVarStore(
            itemVarStore, fvarAxes
        )

        assert adapter.tupleVarData == [
            [
                TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]),
                TupleVariation({"wght": (0, 0.5, 1.0)}, [-20, -80]),
                TupleVariation({"wght": (0.5, 1.0, 1.0)}, [30, 90]),
                TupleVariation(
                    {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100]
                ),
                TupleVariation(
                    {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [50, 110]
                ),
                TupleVariation(
                    {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120]
                ),
            ],
            [
                TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]),
                TupleVariation(
                    {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55]
                ),
                TupleVariation(
                    {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [25, 65]
                ),
                TupleVariation(
                    {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75]
                ),
            ],
        ]
        assert adapter.itemCounts == [data.ItemCount for data in itemVarStore.VarData]
        assert adapter.regions == regions
        assert adapter.axisOrder == axisOrder

        itemVarStore2 = adapter.asItemVarStore()

        assert [
            reg.get_support(fvarAxes) for reg in itemVarStore2.VarRegionList.Region
        ] == regions

        assert itemVarStore2.VarDataCount == 2
        assert itemVarStore2.VarData[0].VarRegionIndex == [0, 1, 2, 4, 5, 6]
        assert itemVarStore2.VarData[0].Item == [
            [10, -20, 30, -40, 50, -60],
            [70, -80, 90, -100, 110, -120],
        ]
        assert itemVarStore2.VarData[1].VarRegionIndex == [3, 4, 5, 6]
        assert itemVarStore2.VarData[1].Item == [[5, -15, 25, -35], [45, -55, 65, -75]]


def makeTTFont(glyphOrder, features):
    font = ttLib.TTFont()
    font.setGlyphOrder(glyphOrder)
    addOpenTypeFeaturesFromString(font, features)
    font["name"] = ttLib.newTable("name")
    return font


def _makeDSAxesDict(axes):
    dsAxes = collections.OrderedDict()
    for axisTag, axisValues in axes:
        axis = designspaceLib.AxisDescriptor()
        axis.name = axis.tag = axis.labelNames["en"] = axisTag
        axis.minimum, axis.default, axis.maximum = axisValues
        dsAxes[axis.tag] = axis
    return dsAxes


def makeVariableFont(masters, baseIndex, axes, masterLocations):
    vf = deepcopy(masters[baseIndex])
    dsAxes = _makeDSAxesDict(axes)
    fvar = varLib._add_fvar(vf, dsAxes, instances=())
    axisTags = [axis.axisTag for axis in fvar.axes]
    normalizedLocs = [models.normalizeLocation(m, dict(axes)) for m in masterLocations]
    model = models.VariationModel(normalizedLocs, axisOrder=axisTags)
    varLib._merge_OTL(vf, model, masters, axisTags)
    return vf


def makeParametrizedVF(glyphOrder, features, values, increments):
    # Create a test VF with given glyphs and parametrized OTL features.
    # The VF is built from 9 masters (3 x 3 along wght and wdth), with
    # locations hard-coded and base master at wght=400 and wdth=100.
    # 'values' is a list of initial values that are interpolated in the
    # 'features' string, and incremented for each subsequent master by the
    # given 'increments' (list of 2-tuple) along the two axes.
    assert values and len(values) == len(increments)
    assert all(len(i) == 2 for i in increments)
    masterLocations = [
        {"wght": 100, "wdth": 50},
        {"wght": 100, "wdth": 100},
        {"wght": 100, "wdth": 150},
        {"wght": 400, "wdth": 50},
        {"wght": 400, "wdth": 100},  # base master
        {"wght": 400, "wdth": 150},
        {"wght": 700, "wdth": 50},
        {"wght": 700, "wdth": 100},
        {"wght": 700, "wdth": 150},
    ]
    n = len(values)
    values = list(values)
    masters = []
    for _ in range(3):
        for _ in range(3):
            master = makeTTFont(glyphOrder, features=features % tuple(values))
            masters.append(master)
            for i in range(n):
                values[i] += increments[i][1]
        for i in range(n):
            values[i] += increments[i][0]
    baseIndex = 4
    axes = [("wght", (100, 400, 700)), ("wdth", (50, 100, 150))]
    vf = makeVariableFont(masters, baseIndex, axes, masterLocations)
    return vf


@pytest.fixture
def varfontGDEF():
    glyphOrder = [".notdef", "f", "i", "f_i"]
    features = (
        "feature liga { sub f i by f_i;} liga;"
        "table GDEF { LigatureCaretByPos f_i %d; } GDEF;"
    )
    values = [100]
    increments = [(+30, +10)]
    return makeParametrizedVF(glyphOrder, features, values, increments)


@pytest.fixture
def varfontGPOS():
    glyphOrder = [".notdef", "V", "A"]
    features = "feature kern { pos V A %d; } kern;"
    values = [-80]
    increments = [(-10, -5)]
    return makeParametrizedVF(glyphOrder, features, values, increments)


@pytest.fixture
def varfontGPOS2():
    glyphOrder = [".notdef", "V", "A", "acutecomb"]
    features = (
        "markClass [acutecomb] <anchor 150 -10> @TOP_MARKS;"
        "feature mark {"
        "  pos base A <anchor %d 450> mark @TOP_MARKS;"
        "} mark;"
        "feature kern {"
        "  pos V A %d;"
        "} kern;"
    )
    values = [200, -80]
    increments = [(+30, +10), (-10, -5)]
    return makeParametrizedVF(glyphOrder, features, values, increments)


class InstantiateOTLTest(object):
    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": -1.0}, 110),  # -60
            ({"wght": 0}, 170),
            ({"wght": 0.5}, 200),  # +30
            ({"wght": 1.0}, 230),  # +60
            ({"wdth": -1.0}, 160),  # -10
            ({"wdth": -0.3}, 167),  # -3
            ({"wdth": 0}, 170),
            ({"wdth": 1.0}, 180),  # +10
        ],
    )
    def test_pin_and_drop_axis_GDEF(self, varfontGDEF, location, expected):
        vf = varfontGDEF
        assert "GDEF" in vf

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateOTL(vf, location)

        assert "GDEF" in vf
        gdef = vf["GDEF"].table
        assert gdef.Version == 0x00010003
        assert gdef.VarStore
        assert gdef.LigCaretList
        caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0]
        assert caretValue.Format == 3
        assert hasattr(caretValue, "DeviceTable")
        assert caretValue.DeviceTable.DeltaFormat == 0x8000
        assert caretValue.Coordinate == expected

    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": -1.0, "wdth": -1.0}, 100),  # -60 - 10
            ({"wght": -1.0, "wdth": 0.0}, 110),  # -60
            ({"wght": -1.0, "wdth": 1.0}, 120),  # -60 + 10
            ({"wght": 0.0, "wdth": -1.0}, 160),  # -10
            ({"wght": 0.0, "wdth": 0.0}, 170),
            ({"wght": 0.0, "wdth": 1.0}, 180),  # +10
            ({"wght": 1.0, "wdth": -1.0}, 220),  # +60 - 10
            ({"wght": 1.0, "wdth": 0.0}, 230),  # +60
            ({"wght": 1.0, "wdth": 1.0}, 240),  # +60 + 10
        ],
    )
    def test_full_instance_GDEF(self, varfontGDEF, location, expected):
        vf = varfontGDEF
        assert "GDEF" in vf

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateOTL(vf, location)

        assert "GDEF" in vf
        gdef = vf["GDEF"].table
        assert gdef.Version == 0x00010000
        assert not hasattr(gdef, "VarStore")
        assert gdef.LigCaretList
        caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0]
        assert caretValue.Format == 1
        assert not hasattr(caretValue, "DeviceTable")
        assert caretValue.Coordinate == expected

    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": -1.0}, -85),  # +25
            ({"wght": 0}, -110),
            ({"wght": 1.0}, -135),  # -25
            ({"wdth": -1.0}, -105),  # +5
            ({"wdth": 0}, -110),
            ({"wdth": 1.0}, -115),  # -5
        ],
    )
    def test_pin_and_drop_axis_GPOS_kern(self, varfontGPOS, location, expected):
        vf = varfontGPOS
        assert "GDEF" in vf
        assert "GPOS" in vf

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateOTL(vf, location)

        gdef = vf["GDEF"].table
        gpos = vf["GPOS"].table
        assert gdef.Version == 0x00010003
        assert gdef.VarStore

        assert gpos.LookupList.Lookup[0].LookupType == 2  # PairPos
        pairPos = gpos.LookupList.Lookup[0].SubTable[0]
        valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1
        assert valueRec1.XAdvDevice
        assert valueRec1.XAdvDevice.DeltaFormat == 0x8000
        assert valueRec1.XAdvance == expected

    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": -1.0, "wdth": -1.0}, -80),  # +25 + 5
            ({"wght": -1.0, "wdth": 0.0}, -85),  # +25
            ({"wght": -1.0, "wdth": 1.0}, -90),  # +25 - 5
            ({"wght": 0.0, "wdth": -1.0}, -105),  # +5
            ({"wght": 0.0, "wdth": 0.0}, -110),
            ({"wght": 0.0, "wdth": 1.0}, -115),  # -5
            ({"wght": 1.0, "wdth": -1.0}, -130),  # -25 + 5
            ({"wght": 1.0, "wdth": 0.0}, -135),  # -25
            ({"wght": 1.0, "wdth": 1.0}, -140),  # -25 - 5
        ],
    )
    def test_full_instance_GPOS_kern(self, varfontGPOS, location, expected):
        vf = varfontGPOS
        assert "GDEF" in vf
        assert "GPOS" in vf

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateOTL(vf, location)

        assert "GDEF" not in vf
        gpos = vf["GPOS"].table

        assert gpos.LookupList.Lookup[0].LookupType == 2  # PairPos
        pairPos = gpos.LookupList.Lookup[0].SubTable[0]
        valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1
        assert not hasattr(valueRec1, "XAdvDevice")
        assert valueRec1.XAdvance == expected

    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": -1.0}, (210, -85)),  # -60, +25
            ({"wght": 0}, (270, -110)),
            ({"wght": 0.5}, (300, -122)),  # +30, -12
            ({"wght": 1.0}, (330, -135)),  # +60, -25
            ({"wdth": -1.0}, (260, -105)),  # -10, +5
            ({"wdth": -0.3}, (267, -108)),  # -3, +2
            ({"wdth": 0}, (270, -110)),
            ({"wdth": 1.0}, (280, -115)),  # +10, -5
        ],
    )
    def test_pin_and_drop_axis_GPOS_mark_and_kern(
        self, varfontGPOS2, location, expected
    ):
        vf = varfontGPOS2
        assert "GDEF" in vf
        assert "GPOS" in vf

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateOTL(vf, location)

        v1, v2 = expected
        gdef = vf["GDEF"].table
        gpos = vf["GPOS"].table
        assert gdef.Version == 0x00010003
        assert gdef.VarStore
        assert gdef.GlyphClassDef

        assert gpos.LookupList.Lookup[0].LookupType == 4  # MarkBasePos
        markBasePos = gpos.LookupList.Lookup[0].SubTable[0]
        baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0]
        assert baseAnchor.Format == 3
        assert baseAnchor.XDeviceTable
        assert baseAnchor.XDeviceTable.DeltaFormat == 0x8000
        assert not baseAnchor.YDeviceTable
        assert baseAnchor.XCoordinate == v1
        assert baseAnchor.YCoordinate == 450

        assert gpos.LookupList.Lookup[1].LookupType == 2  # PairPos
        pairPos = gpos.LookupList.Lookup[1].SubTable[0]
        valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1
        assert valueRec1.XAdvDevice
        assert valueRec1.XAdvDevice.DeltaFormat == 0x8000
        assert valueRec1.XAdvance == v2

    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": -1.0, "wdth": -1.0}, (200, -80)),  # -60 - 10, +25 + 5
            ({"wght": -1.0, "wdth": 0.0}, (210, -85)),  # -60, +25
            ({"wght": -1.0, "wdth": 1.0}, (220, -90)),  # -60 + 10, +25 - 5
            ({"wght": 0.0, "wdth": -1.0}, (260, -105)),  # -10, +5
            ({"wght": 0.0, "wdth": 0.0}, (270, -110)),
            ({"wght": 0.0, "wdth": 1.0}, (280, -115)),  # +10, -5
            ({"wght": 1.0, "wdth": -1.0}, (320, -130)),  # +60 - 10, -25 + 5
            ({"wght": 1.0, "wdth": 0.0}, (330, -135)),  # +60, -25
            ({"wght": 1.0, "wdth": 1.0}, (340, -140)),  # +60 + 10, -25 - 5
        ],
    )
    def test_full_instance_GPOS_mark_and_kern(self, varfontGPOS2, location, expected):
        vf = varfontGPOS2
        assert "GDEF" in vf
        assert "GPOS" in vf

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateOTL(vf, location)

        v1, v2 = expected
        gdef = vf["GDEF"].table
        gpos = vf["GPOS"].table
        assert gdef.Version == 0x00010000
        assert not hasattr(gdef, "VarStore")
        assert gdef.GlyphClassDef

        assert gpos.LookupList.Lookup[0].LookupType == 4  # MarkBasePos
        markBasePos = gpos.LookupList.Lookup[0].SubTable[0]
        baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0]
        assert baseAnchor.Format == 1
        assert not hasattr(baseAnchor, "XDeviceTable")
        assert not hasattr(baseAnchor, "YDeviceTable")
        assert baseAnchor.XCoordinate == v1
        assert baseAnchor.YCoordinate == 450

        assert gpos.LookupList.Lookup[1].LookupType == 2  # PairPos
        pairPos = gpos.LookupList.Lookup[1].SubTable[0]
        valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1
        assert not hasattr(valueRec1, "XAdvDevice")
        assert valueRec1.XAdvance == v2

    def test_GPOS_ValueRecord_XAdvDevice_wtihout_XAdvance(self):
        # Test VF contains a PairPos adjustment in which the default instance
        # has no XAdvance but there are deltas in XAdvDevice (VariationIndex).
        vf = ttLib.TTFont()
        vf.importXML(os.path.join(TESTDATA, "PartialInstancerTest4-VF.ttx"))
        pairPos = vf["GPOS"].table.LookupList.Lookup[0].SubTable[0]
        assert pairPos.ValueFormat1 == 0x40
        valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1
        assert not hasattr(valueRec1, "XAdvance")
        assert valueRec1.XAdvDevice.DeltaFormat == 0x8000
        outer = valueRec1.XAdvDevice.StartSize
        inner = valueRec1.XAdvDevice.EndSize
        assert vf["GDEF"].table.VarStore.VarData[outer].Item[inner] == [-50]

        # check that MutatorMerger for ValueRecord doesn't raise AttributeError
        # when XAdvDevice is present but there's no corresponding XAdvance.
        instancer.instantiateOTL(vf, instancer.NormalizedAxisLimits(wght=0.5))

        pairPos = vf["GPOS"].table.LookupList.Lookup[0].SubTable[0]
        assert pairPos.ValueFormat1 == 0x4
        valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1
        assert not hasattr(valueRec1, "XAdvDevice")
        assert valueRec1.XAdvance == -25


class InstantiateAvarTest(object):
    @pytest.mark.parametrize("location", [{"wght": 0.0}, {"wdth": 0.0}])
    def test_pin_and_drop_axis(self, varfont, location):
        location = instancer.AxisLimits(location)

        instancer.instantiateAvar(varfont, location)

        assert set(varfont["avar"].segments).isdisjoint(location)

    def test_full_instance(self, varfont):
        location = instancer.AxisLimits(wght=0.0, wdth=0.0)

        instancer.instantiateAvar(varfont, location)

        assert "avar" not in varfont

    @staticmethod
    def quantizeF2Dot14Floats(mapping):
        return {
            floatToFixedToFloat(k, 14): floatToFixedToFloat(v, 14)
            for k, v in mapping.items()
        }

    # the following values come from NotoSans-VF.ttf
    DFLT_WGHT_MAPPING = {
        -1.0: -1.0,
        -0.6667: -0.7969,
        -0.3333: -0.5,
        0: 0,
        0.2: 0.18,
        0.4: 0.38,
        0.6: 0.61,
        0.8: 0.79,
        1.0: 1.0,
    }

    DFLT_WDTH_MAPPING = {-1.0: -1.0, -0.6667: -0.7, -0.3333: -0.36664, 0: 0, 1.0: 1.0}

    @pytest.fixture
    def varfont(self):
        fvarAxes = ("wght", (100, 400, 900)), ("wdth", (62.5, 100, 100))
        avarSegments = {
            "wght": self.quantizeF2Dot14Floats(self.DFLT_WGHT_MAPPING),
            "wdth": self.quantizeF2Dot14Floats(self.DFLT_WDTH_MAPPING),
        }
        varfont = ttLib.TTFont()
        varfont["name"] = ttLib.newTable("name")
        varLib._add_fvar(varfont, _makeDSAxesDict(fvarAxes), instances=())
        avar = varfont["avar"] = ttLib.newTable("avar")
        avar.segments = avarSegments
        return varfont

    @pytest.mark.parametrize(
        "axisLimits, expectedSegments",
        [
            pytest.param(
                {"wght": (100, 900)},
                {"wght": DFLT_WGHT_MAPPING, "wdth": DFLT_WDTH_MAPPING},
                id="wght=100:900",
            ),
            pytest.param(
                {"wght": (400, 900)},
                {
                    "wght": {
                        -1.0: -1.0,
                        0: 0,
                        0.2: 0.18,
                        0.4: 0.38,
                        0.6: 0.61,
                        0.8: 0.79,
                        1.0: 1.0,
                    },
                    "wdth": DFLT_WDTH_MAPPING,
                },
                id="wght=400:900",
            ),
            pytest.param(
                {"wght": (100, 400)},
                {
                    "wght": {
                        -1.0: -1.0,
                        -0.6667: -0.7969,
                        -0.3333: -0.5,
                        0: 0,
                        1.0: 1.0,
                    },
                    "wdth": DFLT_WDTH_MAPPING,
                },
                id="wght=100:400",
            ),
            pytest.param(
                {"wght": (400, 800)},
                {
                    "wght": {
                        -1.0: -1.0,
                        0: 0,
                        0.25: 0.22784,
                        0.50006: 0.48103,
                        0.75: 0.77214,
                        1.0: 1.0,
                    },
                    "wdth": DFLT_WDTH_MAPPING,
                },
                id="wght=400:800",
            ),
            pytest.param(
                {"wght": (400, 700)},
                {
                    "wght": {
                        -1.0: -1.0,
                        0: 0,
                        0.3334: 0.2951,
                        0.66675: 0.623,
                        1.0: 1.0,
                    },
                    "wdth": DFLT_WDTH_MAPPING,
                },
                id="wght=400:700",
            ),
            pytest.param(
                {"wght": (400, 600)},
                {
                    "wght": {-1.0: -1.0, 0: 0, 0.5: 0.47363, 1.0: 1.0},
                    "wdth": DFLT_WDTH_MAPPING,
                },
                id="wght=400:600",
            ),
            pytest.param(
                {"wdth": (62.5, 100)},
                {
                    "wght": DFLT_WGHT_MAPPING,
                    "wdth": {
                        -1.0: -1.0,
                        -0.6667: -0.7,
                        -0.3333: -0.36664,
                        0: 0,
                        1.0: 1.0,
                    },
                },
                id="wdth=62.5:100",
            ),
            pytest.param(
                {"wdth": (70, 100)},
                {
                    "wght": DFLT_WGHT_MAPPING,
                    "wdth": {
                        -1.0: -1.0,
                        -0.8334: -0.85364,
                        -0.4166: -0.44714,
                        0: 0,
                        1.0: 1.0,
                    },
                },
                id="wdth=70:100",
            ),
            pytest.param(
                {"wdth": (75, 100)},
                {
                    "wght": DFLT_WGHT_MAPPING,
                    "wdth": {-1.0: -1.0, -0.49994: -0.52374, 0: 0, 1.0: 1.0},
                },
                id="wdth=75:100",
            ),
            pytest.param(
                {"wdth": (77, 100)},
                {
                    "wght": DFLT_WGHT_MAPPING,
                    "wdth": {-1.0: -1.0, -0.54346: -0.56696, 0: 0, 1.0: 1.0},
                },
                id="wdth=77:100",
            ),
            pytest.param(
                {"wdth": (87.5, 100)},
                {"wght": DFLT_WGHT_MAPPING, "wdth": {-1.0: -1.0, 0: 0, 1.0: 1.0}},
                id="wdth=87.5:100",
            ),
        ],
    )
    def test_limit_axes(self, varfont, axisLimits, expectedSegments):
        axisLimits = instancer.AxisLimits(axisLimits)

        instancer.instantiateAvar(varfont, axisLimits)

        newSegments = varfont["avar"].segments
        expectedSegments = {
            axisTag: self.quantizeF2Dot14Floats(mapping)
            for axisTag, mapping in expectedSegments.items()
        }
        assert newSegments == expectedSegments

    @pytest.mark.parametrize(
        "invalidSegmentMap",
        [
            pytest.param({0.5: 0.5}, id="missing-required-maps-1"),
            pytest.param({-1.0: -1.0, 1.0: 1.0}, id="missing-required-maps-2"),
            pytest.param(
                {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.6: 0.4, 1.0: 1.0},
                id="retrograde-value-maps",
            ),
        ],
    )
    def test_drop_invalid_segment_map(self, varfont, invalidSegmentMap, caplog):
        varfont["avar"].segments["wght"] = invalidSegmentMap

        axisLimits = instancer.AxisLimits(wght=(100, 400))

        with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"):
            instancer.instantiateAvar(varfont, axisLimits)

        assert "Invalid avar" in caplog.text
        assert "wght" not in varfont["avar"].segments

    def test_isValidAvarSegmentMap(self):
        assert instancer._isValidAvarSegmentMap("FOOO", {})
        assert instancer._isValidAvarSegmentMap("FOOO", {-1.0: -1.0, 0: 0, 1.0: 1.0})
        assert instancer._isValidAvarSegmentMap(
            "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 1.0: 1.0}
        )
        assert instancer._isValidAvarSegmentMap(
            "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.7: 0.5, 1.0: 1.0}
        )


class InstantiateFvarTest(object):
    @pytest.mark.parametrize(
        "location, instancesLeft",
        [
            (
                {"wght": 400.0},
                ["Regular", "SemiCondensed", "Condensed", "ExtraCondensed"],
            ),
            (
                {"wght": 100.0},
                ["Thin", "SemiCondensed Thin", "Condensed Thin", "ExtraCondensed Thin"],
            ),
            (
                {"wdth": 100.0},
                [
                    "Thin",
                    "ExtraLight",
                    "Light",
                    "Regular",
                    "Medium",
                    "SemiBold",
                    "Bold",
                    "ExtraBold",
                    "Black",
                ],
            ),
            # no named instance at pinned location
            ({"wdth": 90.0}, []),
        ],
    )
    def test_pin_and_drop_axis(self, varfont, location, instancesLeft):
        location = instancer.AxisLimits(location)

        instancer.instantiateFvar(varfont, location)

        fvar = varfont["fvar"]
        assert {a.axisTag for a in fvar.axes}.isdisjoint(location)

        for instance in fvar.instances:
            assert set(instance.coordinates).isdisjoint(location)

        name = varfont["name"]
        assert [
            name.getDebugName(instance.subfamilyNameID) for instance in fvar.instances
        ] == instancesLeft

    def test_full_instance(self, varfont):
        location = instancer.AxisLimits({"wght": 0.0, "wdth": 0.0})

        instancer.instantiateFvar(varfont, location)

        assert "fvar" not in varfont

    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": (30, 40, 700)}, (100, 100, 700)),
            ({"wght": (30, 40, None)}, (100, 100, 900)),
            ({"wght": (30, None, 700)}, (100, 400, 700)),
            ({"wght": (None, 200, 700)}, (100, 200, 700)),
            ({"wght": (40, None, None)}, (100, 400, 900)),
            ({"wght": (None, 40, None)}, (100, 100, 900)),
            ({"wght": (None, None, 700)}, (100, 400, 700)),
            ({"wght": (None, None, None)}, (100, 400, 900)),
        ],
    )
    def test_axis_limits(self, varfont, location, expected):
        location = instancer.AxisLimits(location)

        varfont = instancer.instantiateVariableFont(varfont, location)

        fvar = varfont["fvar"]
        axes = {a.axisTag: a for a in fvar.axes}
        assert axes["wght"].minValue == expected[0]
        assert axes["wght"].defaultValue == expected[1]
        assert axes["wght"].maxValue == expected[2]


class InstantiateSTATTest(object):
    @pytest.mark.parametrize(
        "location, expected",
        [
            ({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]),
            (
                {"wdth": 100},
                ["Thin", "Regular", "Medium", "Black", "Upright", "Normal"],
            ),
        ],
    )
    def test_pin_and_drop_axis(self, varfont, location, expected):
        location = instancer.AxisLimits(location)

        instancer.instantiateSTAT(varfont, location)

        stat = varfont["STAT"].table
        designAxes = {a.AxisTag for a in stat.DesignAxisRecord.Axis}

        assert designAxes == {"wght", "wdth", "ital"}

        name = varfont["name"]
        valueNames = []
        for axisValueTable in stat.AxisValueArray.AxisValue:
            valueName = name.getDebugName(axisValueTable.ValueNameID)
            valueNames.append(valueName)

        assert valueNames == expected

    def test_skip_table_no_axis_value_array(self, varfont):
        varfont["STAT"].table.AxisValueArray = None

        instancer.instantiateSTAT(varfont, instancer.AxisLimits(wght=100))

        assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3
        assert varfont["STAT"].table.AxisValueArray is None

    def test_skip_table_axis_value_array_empty(self, varfont):
        varfont["STAT"].table.AxisValueArray.AxisValue = []

        instancer.instantiateSTAT(varfont, {"wght": 100})

        assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3
        assert not varfont["STAT"].table.AxisValueArray.AxisValue

    def test_skip_table_no_design_axes(self, varfont):
        stat = otTables.STAT()
        stat.Version = 0x00010001
        stat.populateDefaults()
        assert not stat.DesignAxisRecord
        assert not stat.AxisValueArray
        varfont["STAT"].table = stat

        instancer.instantiateSTAT(varfont, {"wght": 100})

        assert not varfont["STAT"].table.DesignAxisRecord

    @staticmethod
    def get_STAT_axis_values(stat):
        axes = stat.DesignAxisRecord.Axis
        result = []
        for axisValue in stat.AxisValueArray.AxisValue:
            if axisValue.Format == 1:
                result.append((axes[axisValue.AxisIndex].AxisTag, axisValue.Value))
            elif axisValue.Format == 3:
                result.append(
                    (
                        axes[axisValue.AxisIndex].AxisTag,
                        (axisValue.Value, axisValue.LinkedValue),
                    )
                )
            elif axisValue.Format == 2:
                result.append(
                    (
                        axes[axisValue.AxisIndex].AxisTag,
                        (
                            axisValue.RangeMinValue,
                            axisValue.NominalValue,
                            axisValue.RangeMaxValue,
                        ),
                    )
                )
            elif axisValue.Format == 4:
                result.append(
                    tuple(
                        (axes[rec.AxisIndex].AxisTag, rec.Value)
                        for rec in axisValue.AxisValueRecord
                    )
                )
            else:
                raise AssertionError(axisValue.Format)
        return result

    def test_limit_axes(self, varfont2):
        axisLimits = instancer.AxisLimits({"wght": (400, 500), "wdth": (75, 100)})

        instancer.instantiateSTAT(varfont2, axisLimits)

        assert len(varfont2["STAT"].table.AxisValueArray.AxisValue) == 5
        assert self.get_STAT_axis_values(varfont2["STAT"].table) == [
            ("wght", (400.0, 700.0)),
            ("wght", 500.0),
            ("wdth", (93.75, 100.0, 100.0)),
            ("wdth", (81.25, 87.5, 93.75)),
            ("wdth", (68.75, 75.0, 81.25)),
        ]

    def test_limit_axis_value_format_4(self, varfont2):
        stat = varfont2["STAT"].table

        axisValue = otTables.AxisValue()
        axisValue.Format = 4
        axisValue.AxisValueRecord = []
        for tag, value in (("wght", 575), ("wdth", 90)):
            rec = otTables.AxisValueRecord()
            rec.AxisIndex = next(
                i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag
            )
            rec.Value = value
            axisValue.AxisValueRecord.append(rec)
        stat.AxisValueArray.AxisValue.append(axisValue)

        instancer.instantiateSTAT(varfont2, instancer.AxisLimits(wght=(100, 600)))

        assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue

        instancer.instantiateSTAT(varfont2, instancer.AxisLimits(wdth=(62.5, 87.5)))

        assert axisValue not in varfont2["STAT"].table.AxisValueArray.AxisValue

    def test_unknown_axis_value_format(self, varfont2, caplog):
        stat = varfont2["STAT"].table
        axisValue = otTables.AxisValue()
        axisValue.Format = 5
        stat.AxisValueArray.AxisValue.append(axisValue)

        with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"):
            instancer.instantiateSTAT(varfont2, instancer.AxisLimits(wght=400))

        assert "Unknown AxisValue table format (5)" in caplog.text
        assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue


def test_setMacOverlapFlags():
    flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND
    flagOverlapSimple = _g_l_y_f.flagOverlapSimple

    glyf = ttLib.newTable("glyf")
    glyf.glyphOrder = ["a", "b", "c"]
    a = _g_l_y_f.Glyph()
    a.numberOfContours = 1
    a.flags = [0]
    b = _g_l_y_f.Glyph()
    b.numberOfContours = -1
    comp = _g_l_y_f.GlyphComponent()
    comp.flags = 0
    b.components = [comp]
    c = _g_l_y_f.Glyph()
    c.numberOfContours = 0
    glyf.glyphs = {"a": a, "b": b, "c": c}

    instancer.setMacOverlapFlags(glyf)

    assert a.flags[0] & flagOverlapSimple != 0
    assert b.components[0].flags & flagOverlapCompound != 0


@pytest.fixture
def varfont2():
    f = ttLib.TTFont(recalcTimestamp=False)
    f.importXML(os.path.join(TESTDATA, "PartialInstancerTest2-VF.ttx"))
    return f


@pytest.fixture
def varfont3():
    f = ttLib.TTFont(recalcTimestamp=False)
    f.importXML(os.path.join(TESTDATA, "PartialInstancerTest3-VF.ttx"))
    return f


def _dump_ttx(ttFont):
    # compile to temporary bytes stream, reload and dump to XML
    tmp = BytesIO()
    ttFont.save(tmp)
    tmp.seek(0)
    ttFont2 = ttLib.TTFont(tmp, recalcBBoxes=False, recalcTimestamp=False)
    s = StringIO()
    ttFont2.saveXML(s)
    return stripVariableItemsFromTTX(s.getvalue())


def _get_expected_instance_ttx(
    name, *locations, overlap=instancer.OverlapMode.KEEP_AND_SET_FLAGS
):
    filename = f"{name}-VF-instance-{','.join(str(loc) for loc in locations)}"
    if overlap == instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS:
        filename += "-no-overlap-flags"
    elif overlap == instancer.OverlapMode.REMOVE:
        filename += "-no-overlaps"
    with open(
        os.path.join(TESTDATA, "test_results", f"{filename}.ttx"),
        "r",
        encoding="utf-8",
    ) as fp:
        return stripVariableItemsFromTTX(fp.read())


class InstantiateVariableFontTest(object):
    @pytest.mark.parametrize(
        "wght, wdth",
        [(100, 100), (400, 100), (900, 100), (100, 62.5), (400, 62.5), (900, 62.5)],
    )
    def test_multiple_instancing(self, varfont2, wght, wdth):
        partial = instancer.instantiateVariableFont(varfont2, {"wght": wght})
        instance = instancer.instantiateVariableFont(partial, {"wdth": wdth})

        expected = _get_expected_instance_ttx("PartialInstancerTest2", wght, wdth)

        assert _dump_ttx(instance) == expected

    def test_default_instance(self, varfont2):
        instance = instancer.instantiateVariableFont(
            varfont2, {"wght": None, "wdth": None}
        )

        expected = _get_expected_instance_ttx("PartialInstancerTest2", 400, 100)

        assert _dump_ttx(instance) == expected

    def test_move_weight_width_axis_default(self, varfont2):
        # https://github.com/fonttools/fonttools/issues/2885
        assert varfont2["OS/2"].usWeightClass == 400
        assert varfont2["OS/2"].usWidthClass == 5

        varfont = instancer.instantiateVariableFont(
            varfont2, {"wght": (100, 500, 900), "wdth": 87.5}
        )

        assert varfont["OS/2"].usWeightClass == 500
        assert varfont["OS/2"].usWidthClass == 4

    @pytest.mark.parametrize(
        "overlap, wght",
        [
            (instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS, 400),
            (instancer.OverlapMode.REMOVE, 400),
            (instancer.OverlapMode.REMOVE, 700),
        ],
    )
    def test_overlap(self, varfont3, wght, overlap):
        pytest.importorskip("pathops")

        location = {"wght": wght}

        instance = instancer.instantiateVariableFont(
            varfont3, location, overlap=overlap
        )

        expected = _get_expected_instance_ttx(
            "PartialInstancerTest3", wght, overlap=overlap
        )

        assert _dump_ttx(instance) == expected

    def test_singlepos(self):
        varfont = ttLib.TTFont(recalcTimestamp=False)
        varfont.importXML(os.path.join(TESTDATA, "SinglePos.ttx"))

        location = {"wght": 280, "opsz": 18}

        instance = instancer.instantiateVariableFont(
            varfont,
            location,
        )

        expected = _get_expected_instance_ttx("SinglePos", *location.values())

        assert _dump_ttx(instance) == expected

    def test_varComposite(self):
        input_path = os.path.join(
            TESTDATA, "..", "..", "..", "ttLib", "data", "varc-6868.ttf"
        )
        varfont = ttLib.TTFont(input_path)

        location = {"wght": 600}

        # We currently do not allow this either; although in theory
        # it should be possible.
        with pytest.raises(
            NotImplementedError,
            match="is not supported.",
        ):
            instance = instancer.instantiateVariableFont(
                varfont,
                location,
            )

        location = {"0000": 0.5}

        with pytest.raises(
            NotImplementedError,
            match="is not supported.",
        ):
            instance = instancer.instantiateVariableFont(
                varfont,
                location,
            )


def _conditionSetAsDict(conditionSet, axisOrder):
    result = {}
    conditionSets = conditionSet.ConditionTable if conditionSet is not None else []
    for cond in conditionSets:
        assert cond.Format == 1
        axisTag = axisOrder[cond.AxisIndex]
        result[axisTag] = (cond.FilterRangeMinValue, cond.FilterRangeMaxValue)
    return result


def _getSubstitutions(gsub, lookupIndices):
    subs = {}
    for index, lookup in enumerate(gsub.LookupList.Lookup):
        if index in lookupIndices:
            for subtable in lookup.SubTable:
                subs.update(subtable.mapping)
    return subs


def makeFeatureVarsFont(conditionalSubstitutions):
    axes = set()
    glyphs = set()
    for region, substitutions in conditionalSubstitutions:
        for box in region:
            axes.update(box.keys())
        glyphs.update(*substitutions.items())

    varfont = ttLib.TTFont()
    varfont.setGlyphOrder(sorted(glyphs))

    fvar = varfont["fvar"] = ttLib.newTable("fvar")
    fvar.axes = []
    for axisTag in sorted(axes):
        axis = _f_v_a_r.Axis()
        axis.axisTag = Tag(axisTag)
        fvar.axes.append(axis)

    featureVars.addFeatureVariations(varfont, conditionalSubstitutions)

    return varfont


class InstantiateFeatureVariationsTest(object):
    @pytest.mark.parametrize(
        "location, appliedSubs, expectedRecords",
        [
            ({"wght": 0}, {}, [({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"})]),
            (
                {"wght": -1.0},
                {"uni0061": "uni0041"},
                [
                    ({"cntr": (0, 0.25)}, {"uni0061": "uni0041"}),
                    ({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"}),
                    ({}, {}),
                ],
            ),
            (
                {"wght": 1.0},
                {"uni0024": "uni0024.nostroke"},
                [
                    (
                        {"cntr": (0.75, 1.0)},
                        {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
                    ),
                    ({}, {}),
                ],
            ),
            (
                {"cntr": 0},
                {},
                [
                    ({"wght": (-1.0, -0.45654)}, {"uni0061": "uni0041"}),
                    ({"wght": (0.20886, 1.0)}, {"uni0024": "uni0024.nostroke"}),
                ],
            ),
            (
                {"cntr": 1.0},
                {"uni0041": "uni0061"},
                [
                    (
                        {"wght": (0.20886, 1.0)},
                        {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
                    ),
                    ({}, {}),
                ],
            ),
            (
                {"cntr": (-0.5, 0, 1.0)},
                {},
                [
                    (
                        {"wght": (0.20886, 1.0), "cntr": (0.75, 1)},
                        {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
                    ),
                    (
                        {"wght": (-1.0, -0.45654), "cntr": (0, 0.25)},
                        {"uni0061": "uni0041"},
                    ),
                    (
                        {"cntr": (0.75, 1.0)},
                        {"uni0041": "uni0061"},
                    ),
                    (
                        {"wght": (0.20886, 1.0)},
                        {"uni0024": "uni0024.nostroke"},
                    ),
                ],
            ),
            (
                {"cntr": (0.8, 0.9, 1.0)},
                {"uni0041": "uni0061"},
                [
                    (
                        {"wght": (0.20886, 1.0)},
                        {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
                    ),
                    (
                        {},
                        {"uni0041": "uni0061"},
                    ),
                ],
            ),
            (
                {"cntr": (0.7, 0.9, 1.0)},
                {"uni0041": "uni0061"},
                [
                    (
                        {"cntr": (-0.7499999999999999, 1.0), "wght": (0.20886, 1.0)},
                        {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
                    ),
                    (
                        {"cntr": (-0.7499999999999999, 1.0)},
                        {"uni0041": "uni0061"},
                    ),
                    (
                        {"wght": (0.20886, 1.0)},
                        {"uni0024": "uni0024.nostroke"},
                    ),
                    (
                        {},
                        {},
                    ),
                ],
            ),
        ],
    )
    def test_partial_instance(self, location, appliedSubs, expectedRecords):
        font = makeFeatureVarsFont(
            [
                ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}),
                ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}),
                (
                    [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}],
                    {"uni0061": "uni0041"},
                ),
            ]
        )

        limits = instancer.NormalizedAxisLimits(location)
        instancer.instantiateFeatureVariations(font, limits)

        gsub = font["GSUB"].table
        featureVariations = gsub.FeatureVariations

        assert featureVariations.FeatureVariationCount == len(expectedRecords)

        axisOrder = [
            a.axisTag
            for a in font["fvar"].axes
            if a.axisTag not in location or isinstance(location[a.axisTag], tuple)
        ]
        for i, (expectedConditionSet, expectedSubs) in enumerate(expectedRecords):
            rec = featureVariations.FeatureVariationRecord[i]
            conditionSet = _conditionSetAsDict(rec.ConditionSet, axisOrder)

            assert conditionSet == expectedConditionSet, i

            subsRecord = rec.FeatureTableSubstitution.SubstitutionRecord[0]
            lookupIndices = subsRecord.Feature.LookupListIndex
            substitutions = _getSubstitutions(gsub, lookupIndices)

            assert substitutions == expectedSubs, i

        appliedLookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex

        assert _getSubstitutions(gsub, appliedLookupIndices) == appliedSubs

    @pytest.mark.parametrize(
        "location, appliedSubs",
        [
            ({"wght": 0, "cntr": 0}, None),
            ({"wght": -1.0, "cntr": 0}, {"uni0061": "uni0041"}),
            ({"wght": 1.0, "cntr": 0}, {"uni0024": "uni0024.nostroke"}),
            ({"wght": 0.0, "cntr": 1.0}, {"uni0041": "uni0061"}),
            (
                {"wght": 1.0, "cntr": 1.0},
                {"uni0041": "uni0061", "uni0024": "uni0024.nostroke"},
            ),
            ({"wght": -1.0, "cntr": 0.3}, None),
        ],
    )
    def test_full_instance(self, location, appliedSubs):
        font = makeFeatureVarsFont(
            [
                ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}),
                ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}),
                (
                    [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}],
                    {"uni0061": "uni0041"},
                ),
            ]
        )
        gsub = font["GSUB"].table
        assert gsub.FeatureVariations
        assert gsub.Version == 0x00010001

        location = instancer.NormalizedAxisLimits(location)

        instancer.instantiateFeatureVariations(font, location)

        assert not hasattr(gsub, "FeatureVariations")
        assert gsub.Version == 0x00010000

        if appliedSubs:
            lookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex
            assert _getSubstitutions(gsub, lookupIndices) == appliedSubs
        else:
            assert not gsub.FeatureList.FeatureRecord

    def test_null_conditionset(self):
        # A null ConditionSet offset should be treated like an empty ConditionTable, i.e.
        # all contexts are matched; see https://github.com/fonttools/fonttools/issues/3211
        font = makeFeatureVarsFont(
            [([{"wght": (-1.0, 1.0)}], {"uni0024": "uni0024.nostroke"})]
        )
        gsub = font["GSUB"].table
        gsub.FeatureVariations.FeatureVariationRecord[0].ConditionSet = None

        location = instancer.NormalizedAxisLimits({"wght": 0.5})
        instancer.instantiateFeatureVariations(font, location)

        assert not hasattr(gsub, "FeatureVariations")
        assert gsub.Version == 0x00010000

        lookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex
        assert _getSubstitutions(gsub, lookupIndices) == {"uni0024": "uni0024.nostroke"}

    def test_unsupported_condition_format(self, caplog):
        font = makeFeatureVarsFont(
            [
                (
                    [{"wdth": (-1.0, -0.5), "wght": (0.5, 1.0)}],
                    {"dollar": "dollar.nostroke"},
                )
            ]
        )
        featureVariations = font["GSUB"].table.FeatureVariations
        rec1 = featureVariations.FeatureVariationRecord[0]
        assert len(rec1.ConditionSet.ConditionTable) == 2
        rec1.ConditionSet.ConditionTable[0].Format = 2

        with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"):
            instancer.instantiateFeatureVariations(
                font, instancer.NormalizedAxisLimits(wdth=0)
            )

        assert (
            "Condition table 0 of FeatureVariationRecord 0 "
            "has unsupported format (2); ignored"
        ) in caplog.text

        # check that record with unsupported condition format (but whose other
        # conditions do not reference pinned axes) is kept as is
        featureVariations = font["GSUB"].table.FeatureVariations
        assert featureVariations.FeatureVariationRecord[0] is rec1
        assert len(rec1.ConditionSet.ConditionTable) == 2
        assert rec1.ConditionSet.ConditionTable[0].Format == 2

    def test_GSUB_FeatureVariations_is_None(self, varfont2):
        varfont2["GSUB"].table.Version = 0x00010001
        varfont2["GSUB"].table.FeatureVariations = None
        tmp = BytesIO()
        varfont2.save(tmp)
        varfont = ttLib.TTFont(tmp)

        # DO NOT raise an exception when the optional 'FeatureVariations' attribute is
        # present but is set to None (e.g. with GSUB 1.1); skip and do nothing.
        assert varfont["GSUB"].table.FeatureVariations is None
        instancer.instantiateFeatureVariations(varfont, {"wght": 400, "wdth": 100})
        assert varfont["GSUB"].table.FeatureVariations is None


class LimitTupleVariationAxisRangesTest:
    def check_limit_single_var_axis_range(self, var, axisTag, axisRange, expected):
        result = instancer.changeTupleVariationAxisLimit(var, axisTag, axisRange)
        print(result)

        assert len(result) == len(expected)
        for v1, v2 in zip(result, expected):
            assert v1.coordinates == pytest.approx(v2.coordinates)
            assert v1.axes.keys() == v2.axes.keys()
            for k in v1.axes:
                p, q = v1.axes[k], v2.axes[k]
                assert p == pytest.approx(q)

    @pytest.mark.parametrize(
        "var, axisTag, newMax, expected",
        [
            (
                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
                "wdth",
                0.5,
                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
            ),
            (
                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
                "wght",
                0.5,
                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [50, 50])],
            ),
            (
                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
                "wght",
                0.8,
                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])],
            ),
            (
                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
                "wght",
                1.0,
                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
            ),
            (TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), "wght", 0.0, []),
            (TupleVariation({"wght": (0.5, 1.0, 1.0)}, [100, 100]), "wght", 0.4, []),
            (
                TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
                "wght",
                0.5,
                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
            ),
            (
                TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
                "wght",
                0.4,
                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])],
            ),
            (
                TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
                "wght",
                0.6,
                [
                    TupleVariation({"wght": (0.0, 0.833334, 1.0)}, [100, 100]),
                    TupleVariation({"wght": (0.833334, 1.0, 1.0)}, [80, 80]),
                ],
            ),
            (
                TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
                "wght",
                0.4,
                [
                    TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
                    TupleVariation({"wght": (0.5, 1.0, 1.0)}, [75, 75]),
                ],
            ),
            (
                TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
                "wght",
                0.5,
                [
                    TupleVariation({"wght": (0.0, 0.4, 1)}, [100, 100]),
                    TupleVariation({"wght": (0.4, 1, 1)}, [62.5, 62.5]),
                ],
            ),
            (
                TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]),
                "wght",
                0.5,
                [TupleVariation({"wght": (1.0, 1.0, 1.0)}, [100, 100])],
            ),
            # test case from https://github.com/fonttools/fonttools/issues/3453
            (
                TupleVariation(
                    {
                        "wght": (0.0, 1.0, 1.0),
                        "ital": (0.0, 0.0, 1.0),  # no-op axis gets dropped
                    },
                    [100, 100],
                ),
                "ital",
                0.0,
                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
            ),
        ],
    )
    def test_positive_var(self, var, axisTag, newMax, expected):
        axisRange = instancer.NormalizedAxisTripleAndDistances(0, 0, newMax)
        self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected)

    @pytest.mark.parametrize(
        "var, axisTag, newMin, expected",
        [
            (
                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
                "wdth",
                -0.5,
                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])],
            ),
            (
                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
                "wght",
                -0.5,
                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [50, 50])],
            ),
            (
                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
                "wght",
                -0.8,
                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])],
            ),
            (
                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
                "wght",
                -1.0,
                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])],
            ),
            (TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), "wght", 0.0, []),
            (
                TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [100, 100]),
                "wght",
                -0.4,
                [],
            ),
            (
                TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
                "wght",
                -0.5,
                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])],
            ),
            (
                TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
                "wght",
                -0.4,
                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])],
            ),
            (
                TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
                "wght",
                -0.6,
                [
                    TupleVariation({"wght": (-1.0, -0.833334, 0.0)}, [100, 100]),
                    TupleVariation({"wght": (-1.0, -1.0, -0.833334)}, [80, 80]),
                ],
            ),
            (
                TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
                "wght",
                -0.4,
                [
                    TupleVariation({"wght": (-1.0, -0.5, -0.0)}, [100, 100]),
                    TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [75, 75]),
                ],
            ),
            (
                TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
                "wght",
                -0.5,
                [
                    TupleVariation({"wght": (-1.0, -0.4, 0.0)}, [100, 100]),
                    TupleVariation({"wght": (-1.0, -1.0, -0.4)}, [62.5, 62.5]),
                ],
            ),
            (
                TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]),
                "wght",
                -0.5,
                [TupleVariation({"wght": (-1.0, -1.0, -1.0)}, [100, 100])],
            ),
        ],
    )
    def test_negative_var(self, var, axisTag, newMin, expected):
        axisRange = instancer.NormalizedAxisTripleAndDistances(newMin, 0, 0, 1, 1)
        self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected)


@pytest.mark.parametrize(
    "oldRange, newLimit, expected",
    [
        ((1.0, -1.0), (-1.0, 0, 1.0), None),  # invalid oldRange min > max
        ((0.6, 1.0), (0, 0, 0.5), None),
        ((-1.0, -0.6), (-0.5, 0, 0), None),
        ((0.4, 1.0), (0, 0, 0.5), (0.8, 1.0)),
        ((-1.0, -0.4), (-0.5, 0, 0), (-1.0, -0.8)),
        ((0.4, 1.0), (0, 0, 0.4), (1.0, 1.0)),
        ((-1.0, -0.4), (-0.4, 0, 0), (-1.0, -1.0)),
        ((-0.5, 0.5), (-0.4, 0, 0.4), (-1.0, 1.0)),
        ((0, 1.0), (-1.0, 0, 0), (0, 0)),  # or None?
        ((-1.0, 0), (0, 0, 1.0), (0, 0)),  # or None?
    ],
)
def test_limitFeatureVariationConditionRange(oldRange, newLimit, expected):
    condition = featureVars.buildConditionTable(0, *oldRange)

    result = instancer.featureVars._limitFeatureVariationConditionRange(
        condition, instancer.NormalizedAxisTripleAndDistances(*newLimit, 1, 1)
    )

    assert result == expected


@pytest.mark.parametrize(
    "limits, expected",
    [
        (["wght=400", "wdth=100"], {"wght": 400, "wdth": 100}),
        (["wght=400:900"], {"wght": (400, 900)}),
        (["wght=400:700:900"], {"wght": (400, 700, 900)}),
        (["slnt=11.4"], {"slnt": 11.399994}),
        (["ABCD=drop"], {"ABCD": None}),
        (["wght=:500:"], {"wght": (None, 500, None)}),
        (["wght=::700"], {"wght": (None, None, 700)}),
        (["wght=200::"], {"wght": (200, None, None)}),
        (["wght=200:300:"], {"wght": (200, 300, None)}),
        (["wght=:300:500"], {"wght": (None, 300, 500)}),
        (["wght=300::700"], {"wght": (300, None, 700)}),
        (["wght=300:700"], {"wght": (300, None, 700)}),
        (["wght=:700"], {"wght": (None, None, 700)}),
        (["wght=200:"], {"wght": (200, None, None)}),
    ],
)
def test_parseLimits(limits, expected):
    limits = instancer.parseLimits(limits)
    expected = instancer.AxisLimits(expected)

    assert limits.keys() == expected.keys()
    for axis, triple in limits.items():
        expected_triple = expected[axis]
        if expected_triple is None:
            assert triple is None
        else:
            assert isinstance(triple, instancer.AxisTriple)
            assert isinstance(expected_triple, instancer.AxisTriple)
            assert triple == pytest.approx(expected_triple)


@pytest.mark.parametrize(
    "limits", [["abcde=123", "=0", "wght=:", "wght=1:", "wght=abcd", "wght=x:y"]]
)
def test_parseLimits_invalid(limits):
    with pytest.raises(ValueError, match="invalid location format"):
        instancer.parseLimits(limits)


@pytest.mark.parametrize(
    "limits, expected",
    [
        # 300, 500 come from the font having 100,400,900 fvar axis limits.
        ({"wght": (100, 400)}, {"wght": (-1.0, 0, 0, 300, 500)}),
        ({"wght": (100, 400, 400)}, {"wght": (-1.0, 0, 0, 300, 500)}),
        ({"wght": (100, 300, 400)}, {"wght": (-1.0, -0.5, 0, 300, 500)}),
    ],
)
def test_normalizeAxisLimits(varfont, limits, expected):
    limits = instancer.AxisLimits(limits)

    normalized = limits.normalize(varfont)

    assert normalized == instancer.NormalizedAxisLimits(expected)


def test_normalizeAxisLimits_no_avar(varfont):
    del varfont["avar"]

    limits = instancer.AxisLimits(wght=(400, 400, 500))
    normalized = limits.normalize(varfont)

    assert normalized["wght"] == pytest.approx((0, 0, 0.2, 300, 500), 1e-4)


def test_normalizeAxisLimits_missing_from_fvar(varfont):
    with pytest.raises(ValueError, match="not present in fvar"):
        instancer.AxisLimits({"ZZZZ": 1000}).normalize(varfont)


def test_sanityCheckVariableTables(varfont):
    font = ttLib.TTFont()
    with pytest.raises(ValueError, match="Missing required table fvar"):
        instancer.sanityCheckVariableTables(font)

    del varfont["glyf"]

    with pytest.raises(ValueError, match="Can't have gvar without glyf"):
        instancer.sanityCheckVariableTables(varfont)


def test_main(varfont, tmpdir):
    fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
    varfont.save(fontfile)
    args = [fontfile, "wght=400"]

    # exits without errors
    assert instancer.main(args) is None


def test_main_exit_nonexistent_file(capsys):
    with pytest.raises(SystemExit):
        instancer.main([""])
    captured = capsys.readouterr()

    assert "No such file ''" in captured.err


def test_main_exit_invalid_location(varfont, tmpdir, capsys):
    fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
    varfont.save(fontfile)

    with pytest.raises(SystemExit):
        instancer.main([fontfile, "wght:100"])
    captured = capsys.readouterr()

    assert "invalid location format" in captured.err


def test_main_exit_multiple_limits(varfont, tmpdir, capsys):
    fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
    varfont.save(fontfile)

    with pytest.raises(SystemExit):
        instancer.main([fontfile, "wght=400", "wght=90"])
    captured = capsys.readouterr()

    assert "Specified multiple limits for the same axis" in captured.err


def test_set_ribbi_bits():
    varfont = ttLib.TTFont()
    varfont.importXML(os.path.join(TESTDATA, "STATInstancerTest.ttx"))

    for location in [instance.coordinates for instance in varfont["fvar"].instances]:
        instance = instancer.instantiateVariableFont(
            varfont, location, updateFontNames=True
        )
        name_id_2 = instance["name"].getDebugName(2)
        mac_style = instance["head"].macStyle
        fs_selection = instance["OS/2"].fsSelection & 0b1100001  # Just bits 0, 5, 6

        if location["ital"] == 0:
            if location["wght"] == 700:
                assert name_id_2 == "Bold", location
                assert mac_style == 0b01, location
                assert fs_selection == 0b0100000, location
            else:
                assert name_id_2 == "Regular", location
                assert mac_style == 0b00, location
                assert fs_selection == 0b1000000, location
        else:
            if location["wght"] == 700:
                assert name_id_2 == "Bold Italic", location
                assert mac_style == 0b11, location
                assert fs_selection == 0b0100001, location
            else:
                assert name_id_2 == "Italic", location
                assert mac_style == 0b10, location
                assert fs_selection == 0b0000001, location


def test_rounds_before_iup():
    """Regression test for fonttools/fonttools#3634, with TTX based on
    reproduction process there."""

    varfont = ttLib.TTFont()
    varfont.importXML(os.path.join(TESTDATA, "3634-VF.ttx"))

    # Instantiate at a new default position, sufficient to cause differences
    # when unrounded but not when rounded.
    partial = instancer.instantiateVariableFont(varfont, {"wght": (401, 401, 900)})

    # Save and reload actual result to recalculate bounding box values, etc.
    bytes_out = BytesIO()
    partial.save(bytes_out)
    bytes_out.seek(0)
    partial = ttLib.TTFont(bytes_out)

    # Load expected result, then save and reload to normalise TTX output.
    expected = ttLib.TTFont()
    expected.importXML(os.path.join(TESTDATA, "test_results", "3634-VF-partial.ttx"))

    bytes_out = BytesIO()
    expected.save(bytes_out)
    bytes_out.seek(0)
    expected = ttLib.TTFont(bytes_out)

    # Serialise actual and expected to TTX strings, and compare.
    string_out = StringIO()
    partial.saveXML(string_out)
    partial_ttx = stripVariableItemsFromTTX(string_out.getvalue())

    string_out = StringIO()
    expected.saveXML(string_out)
    expected_ttx = stripVariableItemsFromTTX(string_out.getvalue())

    assert partial_ttx == expected_ttx
