# Getting started with writing and running tests

This document serves as a starting point for writing tests in the LLK repo. For more details on how the test infrastructure works in detail, read [this document](infra_architecture.md).
If your tests are failing for *no reason*, read our [debugging guide](debugging_guide.md)

## Table of contents
| | |
|:----|:----|
| 1 | [Terminology](#terminology) |
| 2 | [Overview](#overview) |
| 3 | [Functional tests](#functional-tests) |
| 4 | [Writing performance tests](#writing-performance-tests) |
| 5 | [Writing your own template and runtime parameters](#writting-your-own-template-and-runtime-parameters) |
| 6 | [Running the tests](#running-the-tests) |
| 7 | [Where do my compilation artifacts end up?](#where-do-my-compilation-artifacts-end-up) |
| 8 | [How do I see what code did my test cover during execution?](#how-do-i-see-what-code-did-my-tests-cover) |
---

# Terminology

Here are explanations of important terms used throughout entirety of this doc:
- **LLK API** or just **API** →Low-Level Kernel API provided as header-only library defined in `tt_llk_*` folders of this repo.
- **test** → pair of `Python` and `C++` files that levarage some part of testing infrastructure to evaluate LLK code.
- **performance test** → pair of `Python` and `C++` files that levarage some part of testing infrastructure to evaluate some performance characteristic of the kernel code (number of clock cycles needed to execute it etc.).
- **kernel** → `C++` code compiled to `elf` files for `UNPACK`, `MATH` and `PACK` tensix units.
- **parameter** → group of arguments passed to `@parametrized` decorator present before test's `Python` function. This decorator is defined in [param_config.py](../../tests/python_tests/helpers/param_config.py).
- **variant** → tuple of concrete argument values passed as part of one invocation of test's `Python` function.
- **stimuli** → binary blob generated by one of our custom Torch Golden generators defined in [golden_generators.py](../../tests/python_tests/helpers/golden_generators.py)
- **template parameter** → compile time, constexpr value generated by `Python` and placed in variant's `build.h` to be passed as template parameter to LLK API function call.
- **runtime parameter** → values that are passed to API calls as function arguments, `Python` generates a struct that bundles all runtime parameters together. Furthermore, they are loaded to L1 and consumed by all kernels.
- **coverage** → code coverage information collected during kernel execution which is then processed into `html` report.

# Overview

LLK testing infrastructure uses `Python`, more specifically it's testing framework called `pytest` to enumerate test variants from tests provided, compile necessary artefacts, and evaluate whether test kernels produce results in expected error bounds. Kernels themselves are written in `C++` using LLK API, and are compiled using our custom RISC-V compiler, called [`SFPI`](https://github.com/tenstorrent/sfpi), into `elf` files that are loaded to and executed on device using this test infrastructure's runtime.

# Functional Tests

They are used to check if tests written using LLK API perform valid calculations on provided stimuli data. Furthermore, [coverage](https://about.codecov.io/resource/what-is-code-coverage/) information can be collected during kernel execution to see what part of the API was used line-for-line.

## Python part of the test

You should put your code in the `./tt-llk/tests/python_tests/` folder and name it using this template: `test_[my test name].py`. In the following example, `magnificent` will be an alias for `my test name`.

```python
from helpers.golden_generators import SomeGoldenGenerator
from helpers.llk_params import (format_dict, ... Some, Parameters ...)
from helpers.test_variant_parameters import (List,Of,Parameters)
from helpers.stimuli_config import StimuliConfig
from helpers.stimuli_generator import generate_stimuli
from helpers.test_config import TestConfig
from helpers.utils import passed_test


@parametrize(
	format = generate_the_format_list(...),
	parameter1 = generate_the_list_params_1(...),
	parameter2 = generate_the_list_params_2(...),
	parameter3 = generate_the_list_params_3(...)
	...
)
def test_magnificent(
	formats,
	... other magnificent parameters ...
	workers_tensix_coordinates
):
	src_A, tile_cnt_A, src_B, tile_cnt_B = generate_stimuli(
		stimuli_format_A=formats.input_format,
		input_dimensions_A=input_A_dimensions,
		stimuli_format_B=formats.input_format,
		input_dimensions_B=input_B_dimensions,
	)

	generate_golden = get_golden_generator(SomeGoldenGenerator)

	golden_tensor = generate_golden(
		src_A,
		src_B,
		formats.output_format,
		inA_dims= input A dimension provided as test parameter,
		inB_dims= input B dimension provided as test parameter,
	)

	configuration = TestConfig(
		"sources/path/to/magnificent_test.cpp",
		formats,
		templates=[ Template, Parameters, Here ],
		runtimes=[ Runtime, Parameters, Here ],
		variant_stimuli=StimuliConfig(
			src_A,
			formats.input_format,
			src_B,
			formats.input_format,
			formats.output_format,
			tile_count_A=tile_cnt_A,
			tile_count_B=tile_cnt_B,
			tile_count_res=tile_cnt_A,
		),
		...
		Other optional args that have default values
		...
	)

	res_from_L1 = configuration.run(workers_tensix_coordinates)

	assert len(res_from_L1) == len(golden_tensor), "Result tensor and golden tensor are not of the same length"

	res_tensor = torch.tensor(res_from_L1, dtype=format_dict[formats.output_format])

	assert passed_test(golden_tensor, res_tensor, formats.output_format), "Assert against golden failed"
```

### Parametrizing the python test function

You shall add as many arguments as you wish, but `workers_tensix_coordinates` shall be present to enable use of multiple `tensix` cores to execute tests on the card itself. This argument is generated by the same named [pytest fixture](https://betterstack.com/community/guides/testing/pytest-fixtures-guide/#:~:text=Step%202%20%E2%80%94-,Getting%20started%20with%20Pytest%20fixtures,-To%20understand%20how). Other parameters are passed as arguments to `@parametrized` decorator which decorated test function. This decorator is defined in [param_config.py](../../tests/python_tests/helpers/param_config.py). It's used to generated variant IDs and wraps `@pytest.mark.parametrize` decorator. [Here](https://docs.pytest.org/en/stable/how-to/parametrize.html) you can read more about how `pytest` internally generates test variants.

### TestConfig object
This class and its object are used as a one-stop shop for building and executing all tests in the LLK infrastructure in a modular and performative way. It's defined in [test_config.py](../../tests/python_tests/helpers/test_config.py) The following snippet contains all available arguments exposed by the configuration:

```python
def TestConfig.__init__(
	self,
	test_name: str,
	formats: InputOutputFormat = None,
	templates: list[TemplateParameter] = [],
	runtimes: list[RuntimeParameter] = [],
	variant_stimuli: StimuliConfig = None,
	boot_mode: BootMode = BootMode.DEFAULT,
	profiler_build: ProfilerBuild = ProfilerBuild.No,
	L1_to_L1_iterations: int = 1,
	unpack_to_dest: bool = False,
	disable_format_inference: bool = False,
	dest_acc: DestAccumulation = DestAccumulation.No,
	l1_acc: L1Accumulation = L1Accumulation.No,
	skip_build_header: bool = False,
	compile_time_formats: bool = False,
)
```

| Parameter | Description |
|:----|:----|
| `test_name` | A path to the `*.cpp` file of the test itself relative to `./tt-llk/tests` folder, this is the only mandatory argument to the whole object constructor |
| `formats` | Object that contains input and output formats used in the LLK test variants. `InputOutputFormat` itself is dataclass placed in [format_config.py](../../tests/python_tests/helpers/format_config.py#L377) |
| `templates` | A list of template parameters used in C++ LLK API calls, in the tests themselves |
| `runtimes` | A list of parameters passed to all C++ LLK API functions in the tests themselves. These parameters will become fields of the struct that is passed as the `const struct RuntimeParams&` parameter of the kernel testing function itself (this type is abbreviated by `RUNTIME_PARAMETERS` macro included in every `build.h` by default) |
| `variant_stimuli` | Stimuli generated by Torch Golden generators defined in [golden_generators.py](../../tests/python_tests/helpers/golden_generators.py), that should be loaded to L1 before kernel execution starts. It uses [StimuliConfig object](#stimuliconfig-object) defined in [stimuli_config.py](../../tests/python_tests/helpers/stimuli_config.py) |
| `boot_mode` | Should be left to it's default value. It dictates how the kernel is started, [look here](infra_architecture.md#kernel-runtime) for more information. Value of `BootMode.DEFAULT` depends on architecture:<br>- Wormhole and Blackhole - `BootMode.BRISC`<br>- Quasar - `BootMode.TRISC` |
| `profiler_build` | If true, tests are compiled with profiler infrastrucutre enabled, this field shall only be set when `ProfilerConfig` invokes `TestConfig` constructor |
| `L1_to_L1_iterations` | Number of L1 to L1 runs in the kernel itself. This is legacy flag used in few tests written before framework for writing fused tests were written |
| `unpack_to_dest` | `unpack_to_dest` constexpr variable is set to it's value (and placed in variant's `build.h`), which shall be used as template parameter to functions of API.  When set, stimuli is unpacked directly to Dest instead of to srcA/srcB. [Read this](https://github.com/tenstorrent/tt-isa-documentation/blob/main/WormholeB0/TensixTile/TensixCoprocessor/UNPACR_Regular.md) for more details as to what hardware does exactly. This field is used to aid data format inference using `data_format(...)` function from [data_format_inference.py](../../tests/python_tests/helpers/data_format_inference.py#L417) |
| `disable_format_inference` | If true, all stimuli formats (in L1, and in srcA/srcB/Dst regs) are the same. Some edge case handling is performed, read `data_format(...)` function from [data_format_inference.py](../../tests/python_tests/helpers/data_format_inference.py#L417) for more details |
| `dest_acc` | `is_fp32_dest_acc_en` constexpr variable is set to it's value (and placed in variant's `build.h`), which shall be used at template parameter to functions of API. When set, intermediate results of some operations are accumulated in Dest register. [Read this]() for more details as to what hardware does exactly. This field is used to aid data format inference using `data_format(...)` function from [data_format_inference.py](../../tests/python_tests/helpers/data_format_inference.py#L417). Furthermore, for some format combinations, dest accumulation must be turned on, _which is done implicitly_ during data format inference |
| `l1_acc` | If set, intermediate results of some operations are accumulated in L1, this is only used on Quasar |
| `skip_build_header` | Used for fused tests, directs infra to omit generating `build.h` for the specific variant |
| `compile_time_formats` | If true, treats `formats` as compile time arguments, threats them as runtime parameters otherwise |

Both `templates` and `runtimes` lists can be populated with object instances of classes defined in [test_variant_parameters.py](../../tests/python_tests/helpers/test_variant_parameters.py). Importantly, all classes inherited from `class RuntimeParameter(ABC)` can be passed as template parameters too. Passing template parameters as runtimes isn't supported and will cause `Python` runtime errors. This mechanism is explained in greater detail [here](infra_architecture.md#template-and-runtime-parameters) Both types of classes wrap types defined in [llk_params.py](../../tests/python_tests/helpers/llk_params.py) and enable C++ code generation.

### Writing your own `TestConfig` parameters for `C++` part of the test
All parameters are defined in [test_variant_parameters.py](../../tests/python_tests/helpers/test_variant_parameters.py). Both groups are inherited from one of these abstract classes:
```python
@dataclass
class TemplateParameter(ABC):
    @abstractmethod
    def convert_to_cpp(self) -> str:
        pass


@dataclass
class RuntimeParameter(ABC):

    @abstractmethod
    def convert_to_cpp(self) -> str:
        pass

    @abstractmethod
    def convert_to_struct_fields(self) -> tuple[str, str]:
        pass
```

#### Templates
In order to generate constexpr definitions in `build.h`, test infra will call on every template parameter supplied `def convert_to_cpp(self) -> str:`. This overloaded function can use fields of the inherited parameter classes and do with them as it pleases. End result should always be something like:
```python
return f"constexpr auto STOCHASTIC_RND = ckernel::{self.stochastic_rounding.value};"
```
Which will directly be copied to variant's `build.h` as is written here.


#### Runtimes
TODO @ajankovicTT write this


### StimuliConfig object

This class, defined in [stimuli_config.py](../../tests/python_tests/helpers/stimuli_config.py), is used to:
1) generate L1 addresses of the stimuli used as runtime parameters by the kernel
2) serialize (pack) the stimuli into a byte stream in accordance with provided formats
3) write the byte stream to L1, and read the result from L1
4) deserialize (unpack) the aforementioned byte stream and prepare it for assertion (matching to stimuli calculated by Torch Golden).

```python
def __init__(
	self,
	buffer_A,
	stimuli_A_format: DataFormat,
	buffer_B,
	stimuli_B_format: DataFormat,
	stimuli_res_format: DataFormat,
	tile_count_A: int = 1,
	tile_count_B: int = None,
	tile_count_res: int = 1,
	buffer_C=None,
	stimuli_C_format: DataFormat = None,
	tile_count_C: int = None,
	num_faces: int = 4,
	face_r_dim: int = 16,
	tile_dimensions: list[int] = [32, 32],
	sfpu=False,
	write_full_tiles: bool = False,
	use_dense_tile_dimensions: bool = False,
	operand_res_tile_size: int = None,
)
```

| Parameter | Description |
|:----|:----|
| `buffer_[A\|B\|C\|res]` | Byte stream generated by `generate_stimuli` function |
| `stimuli_[A\|B\|C\|res]_formats` | Data format of the respective stimuli operand |
| `tile_count_[A\|B\|C\|res]` | Number of tiles present in the respective stimuli |
| `num_faces`, `face_r_dim`, `tile_dimensions`, `sfpu` | Used to further configure the way byte streams are packed into, and unpacked from L1 |
| `write_full_tiles` | Writes entire tile (1024 datums) to L1, ignores actual datum count inferred from formats and tile dimensions |
| `use_dense_tile_dimensions` | Used for packing/unpacking tiny-tiles |
| `operand_res_tile_size` | Used for explicitly setting tile size of the result, measured in bytes |

### Checking validity of data computed by the kernel

Stimuli operands passed using [StimuliConfig Class](#stimuliconfig-object) are used by the kernel to perform various calculations. Function called `passed_test`, from [utils.py](../../tests/python_tests/helpers/utils.py) is used to assert whether Golden-generated and computed stimuli blobs are within _acceptable margin of error_. This is done by [`torch.isclose(...)`](https://docs.pytorch.org/docs/stable/generated/torch.isclose.html) function. This function uses different tolerances based on stimuli formats, which are passed to aforementioned `torch` function:

| Format | Tolerance |
|:----|:----|
| Float16 | Tolerance(atol=0.05, rtol=0.05) |
| Float16_b | Tolerance(atol=0.05, rtol=0.05) |
| Float32 | Tolerance(atol=0.05, rtol=0.05) |
| Int32 | Tolerance(atol=0, rtol=0) |
| UInt32 | Tolerance(atol=0, rtol=0) |
| Int16 | Tolerance(atol=0, rtol=0) |
| UInt16 | Tolerance(atol=0, rtol=0) |
| Int8 | Tolerance(atol=0, rtol=0) |
| UInt8 | Tolerance(atol=0, rtol=0) |
| Bfp8_b | Tolerance(atol=0.1, rtol=0.2) |
| Bfp4_b | Tolerance(atol=0.25, rtol=0.3) |
| MxFp8R | Tolerance(atol=0.2, rtol=0.3) |
| MxFp8P | Tolerance(atol=0.2, rtol=0.3) |
| Fp8_e4m3 | Tolerance(atol=0.2, rtol=0.2) |

This table is defined [here](../../tests/python_tests/helpers/utils.py#L26)

## C++ part of the test

```cpp
... some magnificent header includes used by all kernels
#include "ckernel.h"
#include "llk_defs.h"


// Globals
... magnificent global variables used by all kernels

#ifdef LLK_TRISC_UNPACK
... magnificent header includes used by UNPACK part of the kernel ...

#include "params.h"
void run_kernel(RUNTIME_PARAMETERS params){
	... magnificent kernel code ...
}

#endif


#ifdef LLK_TRISC_MATH
... magnificent header includes used by MATH part of the kernel ...

#include "params.h"
void run_kernel(RUNTIME_PARAMETERS params){
	... magnificent kernel code ...
}

#endif

#ifdef LLK_TRISC_PACK
... magnificent header includes used by PACK part of the kernel ...

#include "params.h"
void run_kernel(RUNTIME_PARAMETERS params){
	... magnificent kernel code ...
}

#endif
```

Named headers included in this example kernel code are mandatory to be included to ensure successful compilation. Importantly, [params.h](../../tests/helpers/include/params.h) contains template argument definitions alongside `struct RuntimeParams` definition.

# Writing Performance tests

## Python part of the tests

You should put your code in the `./tt-llk/tests/python_tests/` folder and name it using this template: `perf_[my test name].py`. In the following example, `magnificent` will be an alias for `my test name`.

```python
from helpers.llk_params import (PerfRunType, ... Other, Parameters ...)
from helpers.test_variant_parameters import (List,Of,Parameters)
from helpers.profiler import ProfilerConfig


@parametrize(
	format = generate_the_format_list(...),
	parameter1 = generate_the_list_params_1(...),
	parameter2 = generate_the_list_params_2(...),
	parameter3 = generate_the_list_params_3(...)
	...
)

def test_magnificent(
	perf_report
	formats,
	... other magnificent parameters ...
	workers_tensix_coordinates
):
 	configuration = ProfilerConfig(
		"sources/path/to/magnificent_test.cpp",
		formats,
		run_types=[ . . . My run types . . .],
		templates=[ Template, Parameters, Here ],
		runtimes=[ Runtime, Parameters, Here ],
		...
		Other optional args that have default values
		...
	)

	configuration.run(perf_report, location=workers_tensix_coordinates)
```

### Parametrizing the python test function

You shall add as many arguments as you wish, but `workers_tensix_coordinates` and `perf_report` shall always be present. The first enables use of multiple Tensix cores to execute tests on the card itself. The second enables generation of performance reports. Both arguments are generated by the same named [pytest fixtures](https://betterstack.com/community/guides/testing/pytest-fixtures-guide/#:~:text=Step%202%20%E2%80%94-,Getting%20started%20with%20Pytest%20fixtures,-To%20understand%20how). Other parameters are passed as arguments to `@parametrized` decorator which decorated test function. This decorator is defined in [param_config.py](../../tests/python_tests/helpers/param_config.py). It's used to generated variant IDs and wraps `@pytest.mark.parametrize` decorator. [Here](https://docs.pytest.org/en/stable/how-to/parametrize.html) you can read more about how `pytest` internally generates test variants.

### ProfilerConfig object

```python
def ProfilerConfig.__init__(
	self,
	test_name: str,
	formats: FormatConfig = None,
	run_types: list[PerfRunType] = [],
	templates: list[TemplateParameter] = [],
	runtimes: list[RuntimeParameter] = [],
	variant_stimuli: StimuliConfig = None,
	unpack_to_dest=False,
	disable_format_inference=False,
	dest_acc=DestAccumulation.No,
	l1_acc=L1Accumulation.No,
	skip_build_header: bool = False,
	compile_time_formats: bool = False,
)
```

Every argument present in [TestConfig object](#testconfig-object) has the exact same functionality as in this object. The only difference is the `run_types` argument which specifies with what performance markers the tests are compiled with, thus these are treated the same way as template parameters. Available run types are defined in [llk_params.py](../../tests/python_tests/helpers/llk_params.py#L440).

## C++ part of the performance test

```cpp
... some magnificent header includes used by all kernels
#include "ckernel.h"
#include "llk_defs.h"


// Globals
... magnificent global variables used by all kernels

#ifdef LLK_TRISC_UNPACK
... magnificent header includes used by UNPACK part of the kernel ...

#include "params.h"
void run_kernel(RUNTIME_PARAMETERS params){
	{
		ZONE_SCOPED("INIT")
		... magnificent kernel code to be profiled ...
		PROFILER_SYNC();
	}
	{
		ZONE_SCOPED("TILE_LOOP")
		... magnificent kernel code to be profiled ...
		PROFILER_SYNC();
	}
}

#endif


#ifdef LLK_TRISC_MATH
... magnificent header includes used by MATH part of the kernel ...

#include "params.h"
void run_kernel(RUNTIME_PARAMETERS params){
	{
		ZONE_SCOPED("INIT")
		... magnificent kernel code to be profiled ...
		PROFILER_SYNC();
	}
	{
		ZONE_SCOPED("TILE_LOOP")
		... magnificent kernel code to be profiled ...
		PROFILER_SYNC();
	}
}

#endif

#ifdef LLK_TRISC_PACK
... magnificent header includes used by PACK part of the kernel ...

#include "params.h"
void run_kernel(RUNTIME_PARAMETERS params){
	{
		ZONE_SCOPED("INIT")
		... magnificent kernel code to be profiled ...
		PROFILER_SYNC();
	}
	{
		ZONE_SCOPED("TILE_LOOP")
		... magnificent kernel code to be profiled ...
		PROFILER_SYNC();
	}
}

#endif
```

# Running the tests

## Setting up the environment
### LLK Docker image:
1. `cd` yourself to `./tt-llk/tests`
2. run `./setup_testing_env.sh`

### Other docker image or testing environment
1. `cd` yourself to `./tt-llk/tests`
2. run `./setup_external_testing_env.sh`

This will install all the necessary headers for your platform alongside latest [`SFPI`](https://github.com/tenstorrent/sfpi), version.

## Invoking pytest
`cd` yourself to one of the paths:
- `./tt-llk/tests`
- `./tt-llk/tests/python_tests/`

afterwards run:

`pytest ./path/to/my_test_name.py`

This command will serially compile and execute your test's variants.

There are few custom `pytest` command line arguments exposed that enable altering how python tests are executed:

| Argument | Description |
|:----|:----|
| `--coverage` | Instructs compiler to: add coverage counters to kernel binary, generate `.gcno` counter tables for every `elf` file and link every `elf` with [debug L1 layout](infra_architecture.md#debug). Furthermore, instructs test infrastrucre runtime to pull coverage data from device at the end of the kernel and store it to variant's folder for later processing |
| `--compile-producer` | Only compile enumerated test variants |
| `--compile-consumer` | Only execute already compiled enumerated test variants. If `--coverage` is passed, generated coverage information is processed and merged into  `/tmp/tt-llk-build/merged_coverage.info` file |
| `--speed-of-light` | All parameters passed to `TestConfig` or `ProfilerConfig` objects are treated as compile time arguments |
| `--test-order-file ./path/to/file` | Tests names present in the file are executed in the exact order in which they are written. [Read this](debugging_guide.md#test-flakyness) for more details. This feature is still work in progress |
| `--skip-codegen` | Skip C++ code generation for fused tests and use existing files. TODO @mradosavljeviTT explain further what this does |
| `--no-debug-symbols` | Compile elfs without debug symbols (`-g` sfpi flag) to save disk space |
| `--logging-level [TRACE \| DEBUG \| INFO \| WARNING \| ERROR \| CRITICAL]` | Sets loguru log level. Overrides `LOGURU_LEVEL` env var. Default: INFO |
| `--detailed-artefacts` | Adds `-save-temps=obj -fdump-tree-all -fdump-rtl-all -v` compilation flags when compiling kernel elfs, allowing generation of compiler level debug information, used when debugging compiler bugs |

If you have many variants that need be compiled, you should run

`pytest --compile-producer -n 10 -x ./my_test_name.py`

which will first compile all your variants using 10 CPU cores of your host machine. Then you run all your compiled tests with

`pytest --compile-consumer -n 10 -x ./my_test_name.py`

which will run tests on 10 Tensix tiles on Wormhole and Blackhole in parallel.

For simulation/emulation (eg. architecture bring-up) procedure is different. TODO @amokanTT, @fvranicTT write this.

You can run your pre-compiled tests as many times as you like. This is useful when you're experimenting with different **runtime arguments** or **stimuli generation**
functions. Keep in mind that if you change any other parameters (eg. templates or any other arguments to constructors) you **must** recompile your test variant by running the first `pytest` command. If you don't do that, you'll probably get `TTException` that states that the elf file can't be found or a stimuli assertion error.

### Collecting coverage data

For collecting code coverage data, you just pass `--coverage` flag to both `pytest` commands. If you want to collect coverage, you **must** compile your variants with coverage flag enabled to be able to collect that information. Coverage **can't** be collected on performance tests, because collected performance data wouldn't adequatelly represent real-world kernel performance.

# Where do my compilation artifacts end up?

Default build directory is at `/tmp/tt-llk-build/`. Your test's artifacts will be at the same path as the one provided in the `test_name` argument in the `TestConfig`/`ProfilerConfig` object. All variants will be placed in folders corresponding to the hash of their compile time arguments. Header file `build.h` generated using passed configuration parameters is placed in its variant's folder.
If coverage is enabled, all coverage data is merged at the end of execution of tests and placed in the `/tmp/tt-llk-build/merged_coverage.info` file. For more details consult [this section](infra_architecture.md#file-and-artefact-paths)

# How do I see what code did my tests cover?

After finishing compilation and execution of your tests with coverage enabled, run `./generate_coverage_report.sh` placed in the `./tt-llk/tests` folder. The generated report is placed in the same folder as your `./tt-llk` repo. For convenient preview of the coverage data, I'd recommend installing the [Live Server VSCode extension](https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer). When it opens your browser tab, you'll see the `./coverage_report` folder. By clicking on it, you'll see `html` report of lines, files and folders your run covered.
