#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
#  Tests for hammer-vlsi CLIDriver
#
#  See LICENSE for licence details.

import json
import os
import shutil
import tempfile
import re
from decimal import Decimal
from typing import Any, Callable, Dict, List, Optional

import hammer_config
from hammer_config import HammerJSONEncoder
from hammer_logging.test import HammerLoggingCaptureContext
from hammer_tech import MacroSize
from hammer_vlsi import CLIDriver, HammerDriver, HammerDriverOptions, HammerVLSISettings, PlacementConstraint, PlacementConstraintType
from hammer_utils import deepdict

import unittest


class CLIDriverTest(unittest.TestCase):
    @staticmethod
    def generate_dummy_config(syn_rundir: str, config_path: str, top_module: str, postprocessing_func: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None) -> Dict[str, Any]:
        """
        Generate and write a dummy config to the given path.
        :param syn_rundir: Directory to set as the synthesis rundir.
        :param config_path: Path to which to write the config.
        :param top_module: Module to set as the top module.
        :param postprocessing_func: Optional function to modify/add to the config.
        :return: Config dictionary that was written.
        """
        config = {
            "vlsi.core.technology": "nop",
            "vlsi.core.synthesis_tool": "mocksynth",
            "vlsi.core.par_tool": "nop",
            "synthesis.inputs.top_module": top_module,
            "synthesis.inputs.input_files": ("/dev/null",),
            "synthesis.mocksynth.temp_folder": syn_rundir
        }  # type: Dict[str, Any]

        if postprocessing_func is not None:
            config = postprocessing_func(config)

        with open(config_path, "w") as f:
            f.write(json.dumps(config, cls=HammerJSONEncoder, indent=4))

        return config

    def run_syn_to_par_with_output(self, config_path: str, syn_rundir: str,
                                   par_rundir: str,
                                   syn_out_path: str,
                                   syn_to_par_out_path: str) -> None:
        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "syn",  # action
                "-p", config_path,
                "--output", syn_out_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", par_rundir
            ])
        self.assertEqual(cm.exception.code, 0)
        # Now run syn-to-par with the main config as well as the outputs.
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "syn-to-par",  # action
                "-p", config_path,
                "-p", syn_out_path,
                "--output", syn_to_par_out_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", par_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

    def test_syn_to_par(self) -> None:
        """
        Test that syn-to-par works with the output-only JSON.
        """

        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()
        par_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")
        syn_out_path = os.path.join(syn_rundir, "syn_out.json")
        syn_to_par_out_path = os.path.join(syn_rundir, "syn_par_out.json")
        self.generate_dummy_config(syn_rundir, config_path, top_module)

        self.run_syn_to_par_with_output(config_path, syn_rundir, par_rundir,
                                        syn_out_path, syn_to_par_out_path)

        # synthesis output should NOT keep other settings
        with open(syn_out_path, "r") as f:
            syn_output = json.loads(f.read())
            self.assertEqual(syn_output["synthesis.outputs.output_files"], ["/dev/null"])
            self.assertFalse("vlsi.core.technology" in syn_output)
        # Generated par input should have other settings
        with open(syn_to_par_out_path, "r") as f:
            par_input = json.loads(f.read())
            self.assertEqual(par_input["par.inputs.top_module"], top_module)
            # par-input should preserve other settings
            self.assertEqual(par_input["vlsi.core.technology"], "nop")

        # Cleanup
        shutil.rmtree(syn_rundir)
        shutil.rmtree(par_rundir)

    def test_syn_to_par_full(self) -> None:
        """
        Test that syn-to-par works with the full JSON.
        """

        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()
        par_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")
        syn_out_full_path = os.path.join(syn_rundir, "syn-output-full.json")
        syn_to_par_out_path = os.path.join(syn_rundir, "syn_par_out.json")
        self.generate_dummy_config(syn_rundir, config_path, top_module)

        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "syn", # action
                "-p", config_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", par_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Now run syn-to-par with the main config as well as the outputs.
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "syn-to-par", # action
                "-p", syn_out_full_path,
                "--output", syn_to_par_out_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", par_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # synthesis full output should keep other settings
        with open(syn_out_full_path, "r") as f:
            syn_output = json.loads(f.read())
            self.assertEqual(syn_output["synthesis.outputs.output_files"], ["/dev/null"])
            self.assertEqual(syn_output["vlsi.core.technology"], "nop")
        # Generated par input should have other settings
        with open(syn_to_par_out_path, "r") as f:
            par_input = json.loads(f.read())
            self.assertEqual(par_input["par.inputs.top_module"], top_module)
            # par-input should preserve other settings
            self.assertEqual(par_input["vlsi.core.technology"], "nop")

        # Cleanup
        shutil.rmtree(syn_rundir)
        shutil.rmtree(par_rundir)

    def test_syn_to_par_improper(self) -> None:
        """
        Test that appropriate error messages are raised when syn-to-par
        is used on a config that does not have outputs.
        """

        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()
        par_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")
        log_path = os.path.join(syn_rundir, "log.txt")
        self.generate_dummy_config(syn_rundir, config_path, top_module)

        with HammerLoggingCaptureContext() as c:
            # Running syn-to-par on a not-output config should fail.
            with self.assertRaises(SystemExit) as cm:  # type: ignore
                CLIDriver().main(args=[
                    "syn-to-par",  # action
                    "-p", config_path,
                    "--log", log_path,
                    "--syn_rundir", syn_rundir,
                    "--par_rundir", par_rundir
                ])
            self.assertEqual(cm.exception.code, 1)
        self.assertTrue(c.log_contains("Input config does not appear to contain valid synthesis outputs"))

        # Cleanup
        shutil.rmtree(syn_rundir)
        shutil.rmtree(par_rundir)

    def test_syn_to_par_same_as_syn_par(self) -> None:
        """
        Test that syn-par generates the same par input as calling syn,
        syn-to-par.
        """

        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()
        par_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")
        syn_out_path = os.path.join(syn_rundir, "syn_out.json")
        syn_to_par_out_path = os.path.join(syn_rundir, "syn_par_out.json")
        self.generate_dummy_config(syn_rundir, config_path, top_module)

        # Run syn-to-par
        self.run_syn_to_par_with_output(config_path, syn_rundir, par_rundir,
                                        syn_out_path, syn_to_par_out_path)

        # Run syn-par
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "syn-par", # action
                "-p", config_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", par_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Check that the syn-to-par generated par input and the syn-par
        # generated par input are the same, modulo any synthesis.outputs.*
        # settings since they don't matter for par input.
        with open(syn_to_par_out_path, "r") as f:
            with open(os.path.join(syn_rundir, "par-input.json"), "r") as f2:
                syn_to_par_output = json.loads(f.read())
                syn_to_par_output = dict(filter(lambda i: "synthesis.outputs." not in i[0], syn_to_par_output.items()))
                syn_par_output = json.loads(f2.read())
                syn_par_output = dict(filter(lambda i: "synthesis.outputs." not in i[0], syn_par_output.items()))
                self.assertEqual(syn_to_par_output, syn_par_output)

        # Cleanup
        shutil.rmtree(syn_rundir)
        shutil.rmtree(par_rundir)

    def test_syn_par_config_dumping(self) -> None:
        """
        Test that the syn_par step (running both synthesis and place-and-route) dumps the intermediate config files,
        namely synthesis output config and par input config.
        """

        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()
        par_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")
        self.generate_dummy_config(syn_rundir, config_path, top_module)

        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "syn-par", # action
                "-p", config_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", par_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Check that the synthesis output and par input configs got dumped.
        with open(os.path.join(syn_rundir, "syn-output.json"), "r") as f:
            syn_output = json.loads(f.read())
            self.assertEqual(syn_output["synthesis.outputs.output_files"], ["/dev/null"])
            # syn-output should NOT keep other settings
            self.assertFalse("vlsi.core.technology" in syn_output)
        with open(os.path.join(syn_rundir, "syn-output-full.json"), "r") as f:
            syn_output_full = json.loads(f.read())
            self.assertEqual(syn_output_full["synthesis.outputs.output_files"], ["/dev/null"])
            # syn-output-full should preserve other settings
            self.assertEqual(syn_output_full["vlsi.core.technology"], "nop")
        with open(os.path.join(syn_rundir, "par-input.json"), "r") as f:
            par_input = json.loads(f.read())
            self.assertEqual(par_input["par.inputs.top_module"], top_module)
            # par-input should preserve other settings
            self.assertEqual(par_input["vlsi.core.technology"], "nop")

        # Cleanup
        shutil.rmtree(syn_rundir)
        shutil.rmtree(par_rundir)

    def test_dump(self) -> None:
        """
        Test that dump works properly.
        """
        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        output_path = os.path.join(syn_rundir, "output.json")
        config_packed_path = os.path.join(syn_rundir, "run_config_packed.json")
        config_path = os.path.join(syn_rundir, "run_config.json")
        self.generate_dummy_config(syn_rundir, config_packed_path, top_module)
        # Equivalent config to above but not unpacked
        with open(config_packed_path, "r") as f:
            unpacked_config = hammer_config.reverse_unpack(json.loads(f.read()))
        with open(config_path, "w") as f:
            f.write(json.dumps(unpacked_config, cls=HammerJSONEncoder, indent=4))

        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "dump",  # action
                "-p", config_path,
                "--output", output_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", syn_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Check that dumped output should be same as what we read in.
        with open(output_path, "r") as f:
            dumped_output = json.loads(f.read())
        with open(config_packed_path, "r") as f:
            packed_output = json.loads(f.read())
        self.assertEqual(packed_output, dumped_output)

        # Cleanup
        shutil.rmtree(syn_rundir)

    def test_hier_dump(self) -> None:
        """
        Test that hierarchical settings work properly.
        """
        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")

        def add_hier(d: Dict[str, Any]) -> Dict[str, Any]:
            output = deepdict(d)
            dummy_placement = PlacementConstraint(
                path="dummy",
                type=PlacementConstraintType.Dummy,
                x=Decimal("0"),
                y=Decimal("0"),
                width=Decimal("10"),
                height=Decimal("10"),
                master=None,
                create_physical=None,
                orientation=None,
                margins=None,
                top_layer=None,
                layers=None,
                obs_types=None).to_dict()
            output["vlsi.inputs.default_output_load"] = 1
            output["vlsi.inputs.hierarchical.top_module"] = top_module
            output["vlsi.inputs.hierarchical.flat"] = "hierarchical"
            output["vlsi.inputs.hierarchical.config_source"] = "manual"
            output["vlsi.inputs.hierarchical.manual_modules"] = [
                {"mod1": ["m1s1", "m1s2"], "mod2": ["m2s1"], top_module: ["mod1", "mod2"]}]
            manual_constraints = [{"mod1": [dummy_placement]},
                                  {"mod2": [dummy_placement]},
                                  {"m1s1": [dummy_placement]},
                                  {"m1s2": [dummy_placement]},
                                  {"m2s1": [dummy_placement]},
                                  {top_module: [dummy_placement]}]
            output["vlsi.inputs.hierarchical.manual_placement_constraints"] = manual_constraints
            output["vlsi.inputs.hierarchical.constraints"] = [{"mod1": [{"vlsi.inputs.default_output_load": 2}]},
                                                              {"m2s1": [{"vlsi.inputs.default_output_load": 3}]}]
            return output

        self.generate_dummy_config(
            syn_rundir, config_path, top_module, postprocessing_func=add_hier)

        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "auto",  # action
                "-p", config_path,
                "--obj_dir", syn_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Check that dumped output should be same as what we read in.
        with open(os.path.join(syn_rundir, "syn-m2s1/full_config.json"), "r") as f:
            dumped_output = json.loads(f.read())
        self.assertEqual(dumped_output['vlsi.inputs.default_output_load'], 3)
        with open(os.path.join(syn_rundir, "syn-m1s1/full_config.json"), "r") as f:
            dumped_output = json.loads(f.read())
        self.assertEqual(dumped_output['vlsi.inputs.default_output_load'], 1)
        with open(os.path.join(syn_rundir, "syn-mod1/full_config.json"), "r") as f:
            dumped_output = json.loads(f.read())
        self.assertEqual(dumped_output['vlsi.inputs.default_output_load'], 2)

        # Cleanup
        shutil.rmtree(syn_rundir)

    def test_hier_dump_empty_constraints(self) -> None:
        """
        Test that hierarchical settings work properly even when no constraints
        are given.
        """
        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")

        def add_hier(d: Dict[str, Any]) -> Dict[str, Any]:
            output = deepdict(d)
            output["vlsi.inputs.default_output_load"] = 1
            output["vlsi.inputs.hierarchical.top_module"] = top_module
            output["vlsi.inputs.hierarchical.flat"] = "hierarchical"
            output["vlsi.inputs.hierarchical.config_source"] = "manual"
            output["vlsi.inputs.hierarchical.manual_modules"] = [
                {"mod1": ["m1s1", "m1s2"], "mod2": ["m2s1"], top_module: ["mod1", "mod2"]}]
            output["vlsi.inputs.hierarchical.manual_placement_constraints"] = []
            output["vlsi.inputs.hierarchical.constraints"] = []
            return output

        self.generate_dummy_config(
            syn_rundir, config_path, top_module, postprocessing_func=add_hier)

        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "auto",  # action
                "-p", config_path,
                "--obj_dir", syn_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Cleanup
        shutil.rmtree(syn_rundir)

    def test_dump_macrosizes(self) -> None:
        """
        Test that dump-macrosizes works properly.
        """
        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        output_path = os.path.join(syn_rundir, "output.json")
        config_path = os.path.join(syn_rundir, "run_config.json")

        my_size = MacroSize(
            library='my_lib',
            name='my_cell',
            width=Decimal("100"),
            height=Decimal("100")
        )

        def add_macro_sizes(d: Dict[str, Any]) -> Dict[str, Any]:
            output = deepdict(d)
            output["vlsi.technology.extra_macro_sizes"] = [my_size.to_setting()]
            return output

        self.generate_dummy_config(syn_rundir, config_path, top_module, postprocessing_func=add_macro_sizes)

        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            CLIDriver().main(args=[
                "dump-macrosizes",  # action
                "-p", config_path,
                "--output", output_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", syn_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Check that dumped output should be same as what we read in.
        with open(output_path, "r") as f:
            dumped_output = json.loads(f.read())
            self.assertEqual(dumped_output, [my_size.to_setting()])

        # Cleanup
        shutil.rmtree(syn_rundir)

    def test_override_actions(self) -> None:
        """
        Test that we can override actions like synthesis_action etc in subclasses.
        """

        class OverriddenDriver(CLIDriver):
            synthesis_called = False  # type: bool

            def synthesis_action(self, driver: HammerDriver,
                                 append_error_func: Callable[[str], None]) -> Optional[Dict]:
                def post_run_func(driver: HammerDriver) -> None:
                    OverriddenDriver.synthesis_called = True

                return self.create_action(action_type="synthesis", extra_hooks=None, post_run_func=post_run_func)(
                    driver, append_error_func)

        # Set up some temporary folders for the unit test.
        syn_rundir = tempfile.mkdtemp()

        # Generate a config for testing.
        top_module = "dummy"
        config_path = os.path.join(syn_rundir, "run_config.json")
        self.generate_dummy_config(syn_rundir, config_path, top_module)

        # Check that running the CLIDriver executes successfully (code 0).
        with self.assertRaises(SystemExit) as cm:  # type: ignore
            OverriddenDriver().main(args=[
                "syn",  # action
                "-p", config_path,
                "--syn_rundir", syn_rundir,
                "--par_rundir", syn_rundir
            ])
        self.assertEqual(cm.exception.code, 0)

        # Check that our synthesis function got called.
        self.assertEqual(OverriddenDriver.synthesis_called, True)

        # Cleanup
        shutil.rmtree(syn_rundir)

    def test_bad_override(self) -> None:
        """Test that a bad override of e.g. synthesis_action is caught."""
        with self.assertRaises(TypeError):
            class BadOverride(CLIDriver):
                def synthesis_action(self, bad: int) -> dict:  # type: ignore
                    return {bad: "bad"}
            BadOverride()


class HammerBuildSystemsTest(unittest.TestCase):

    def _read_targets_from_makefile(self, lines: List[str]) -> Dict[str, List[str]]:
        """
        Helper method to read information about targets from lines of a Makefile.
        """
        targets = {}  # type: Dict[str, List[str]]

        for line in lines:
            # This regex is looking for all non-special targets (i.e. those that aren't .PHONY, .INTERMEDIATE, .SECONDARY, ...)
            # These are of the format (target_name: list of prereqs ...)
            m = re.match(r"^([^.\s:][^\s:]*)\s*:(.*)$", line)
            if m:
                t = m.group(1)
                p = re.split(r"\s+", m.group(2))
                self.assertFalse(t in targets, "Found duplicate target {}".format(t))
                targets[t] = p

        return targets

    def test_flat_makefile(self) -> None:
        """
        Test that a Makefile for a flat design is generated correctly.
        """
        tmpdir = tempfile.mkdtemp()
        proj_config = os.path.join(tmpdir, "config.json")

        settings = {
                "vlsi.core.technology": "nop",
                "vlsi.core.build_system": "make",
                "synthesis.inputs.top_module": "TopMod"
            }
        with open(proj_config, "w") as f:
            f.write(json.dumps(settings, cls=HammerJSONEncoder, indent=4))

        options = HammerDriverOptions(
            environment_configs=[],
            project_configs=[proj_config],
            log_file=os.path.join(tmpdir, "log.txt"),
            obj_dir=tmpdir
        )

        self.assertTrue(HammerVLSISettings.set_hammer_vlsi_path_from_environment(),
            "hammer_vlsi_path must exist")
        driver = HammerDriver(options)

        CLIDriver.generate_build_inputs(driver, lambda x: None)

        d_file = os.path.join(driver.obj_dir, "hammer.d")
        self.assertTrue(os.path.exists(d_file))

        with open(d_file, "r") as f:
            contents = f.readlines()

        targets = self._read_targets_from_makefile(contents)

        tasks = {"pcb", "sim-rtl", "syn", "sim-syn", "par", "sim-par", "power-par", "drc", "lvs"}
        expected_targets = tasks.copy()
        expected_targets.update({"redo-" + x for x in tasks if x is not "pcb"})
        expected_targets.update({os.path.join(tmpdir, x + "-rundir", x + "-output-full.json") for x in tasks if x not in {"sim-rtl", "sim-syn", "sim-par", "power-par"}})
        expected_targets.update({os.path.join(tmpdir, x + "-rundir", x.split('-')[0] + "-output-full.json") for x in tasks if x in {"sim-rtl", "sim-syn", "sim-par", "power-par"}})
        expected_targets.update({os.path.join(tmpdir, x + "-input.json") for x in tasks if x not in {"syn", "pcb", "sim-rtl", "power-par"}})
        expected_targets.update({os.path.join(tmpdir, x + "-input.json") for x in {"power-par", "power-sim-par"}})

        self.assertEqual(set(targets.keys()), set(expected_targets))

        # TODO at some point we should add more tests

        # Cleanup
        shutil.rmtree(tmpdir)

    def test_hier_makefile(self) -> None:
        """
        Test that a Makefile for a hierarchical design is generated correctly.
        """
        tmpdir = tempfile.mkdtemp()
        proj_config = os.path.join(tmpdir, "config.json")

        settings = {
                "vlsi.core.technology": "nop",
                "vlsi.core.build_system": "make",
                "vlsi.inputs.hierarchical.mode": "hierarchical",
                "vlsi.inputs.hierarchical.top_module": "TopMod",
                "vlsi.inputs.hierarchical.config_source": "manual",
                "vlsi.inputs.hierarchical.manual_modules": [{"TopMod": ["SubModA", "SubModB"]}],
                "vlsi.inputs.hierarchical.manual_placement_constraints": [
                    {"TopMod": [
                        {"path": "top", "type": "toplevel", "x": 0, "y": 0, "width": 1234, "height": 7890, "margins": {"left": 1, "top": 2, "right": 3, "bottom": 4}},
                        {"path": "top/C", "type": "placement", "x": 2, "y": 102, "width": 30, "height": 40},
                        {"path": "top/B", "type": "hierarchical", "x": 10, "y": 30, "master": "SubModB"},
                        {"path": "top/A", "type": "hierarchical", "x": 200, "y": 120, "master": "SubModA"}]},
                    {"SubModA": [
                        {"path": "a", "type": "toplevel", "x": 0, "y": 0, "width": 100, "height": 200, "margins": {"left": 0, "top": 0, "right": 0, "bottom": 0}}]},
                    {"SubModB": [
                        {"path": "b", "type": "toplevel", "x": 0, "y": 0, "width": 340, "height": 160, "margins": {"left": 0, "top": 0, "right": 0, "bottom": 0}}]}
                ]
            }
        with open(proj_config, "w") as f:
            f.write(json.dumps(settings, cls=HammerJSONEncoder, indent=4))

        options = HammerDriverOptions(
            environment_configs=[],
            project_configs=[proj_config],
            log_file=os.path.join(tmpdir, "log.txt"),
            obj_dir=tmpdir
        )

        self.assertTrue(HammerVLSISettings.set_hammer_vlsi_path_from_environment(),
            "hammer_vlsi_path must exist")
        driver = HammerDriver(options)

        CLIDriver.generate_build_inputs(driver, lambda x: None)

        d_file = os.path.join(driver.obj_dir, "hammer.d")
        self.assertTrue(os.path.exists(d_file))

        with open(d_file, "r") as f:
            contents = f.readlines()

        targets = self._read_targets_from_makefile(contents)

        mods = {"TopMod", "SubModA", "SubModB"}
        expected_targets = {"pcb", os.path.join(tmpdir, "pcb-rundir", "pcb-output-full.json")}
        expected_targets.update({"sim-rtl-" + x for x in mods})
        expected_targets.update({"syn-" + x for x in mods})
        expected_targets.update({"sim-syn-" + x for x in mods})
        expected_targets.update({"par-" + x for x in mods})
        expected_targets.update({"sim-par-" + x for x in mods})
        expected_targets.update({"power-par-" + x for x in mods})
        expected_targets.update({"lvs-" + x for x in mods})
        expected_targets.update({"drc-" + x for x in mods})
        expected_targets.update({"redo-sim-rtl-" + x for x in mods})
        expected_targets.update({"redo-syn-" + x for x in mods})
        expected_targets.update({"redo-sim-syn-" + x for x in mods})
        expected_targets.update({"redo-par-" + x for x in mods})
        expected_targets.update({"redo-sim-par-" + x for x in mods})
        expected_targets.update({"redo-power-par-" + x for x in mods})
        expected_targets.update({"redo-lvs-" + x for x in mods})
        expected_targets.update({"redo-drc-" + x for x in mods})
        expected_targets.update({os.path.join(tmpdir, "sim-rtl-" + x, "sim-output-full.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "syn-" + x, "syn-output-full.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "sim-syn-" + x, "sim-output-full.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "par-" + x, "par-output-full.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "sim-par-" + x, "sim-output-full.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "power-par-" + x, "power-output-full.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "lvs-" + x, "lvs-output-full.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "drc-" + x, "drc-output-full.json") for x in mods})
        # Only non-leafs get a syn-*-input.json target
        expected_targets.update({os.path.join(tmpdir, "syn-" + x + "-input.json") for x in mods if x in {"TopMod"}})
        expected_targets.update({os.path.join(tmpdir, "sim-syn-" + x + "-input.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "par-" + x + "-input.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "sim-par-" + x + "-input.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "power-par-" + x + "-input.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "power-sim-par-" + x + "-input.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "lvs-" + x + "-input.json") for x in mods})
        expected_targets.update({os.path.join(tmpdir, "drc-" + x + "-input.json") for x in mods})

        self.assertEqual(set(targets.keys()), expected_targets)

        # TODO at some point we should add more tests

        # Cleanup
        shutil.rmtree(tmpdir)



if __name__ == '__main__':
     unittest.main()