Effigy.Tests/PngTests.cs
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// The PNG encoder, taken apart again.
///
/// A written file that no reader accepts is the failure worth guarding, and "it did not throw" says
/// nothing about that. So this decodes what it wrote: the signature, the IHDR fields, every chunk's
/// CRC, and the IDAT inflated back to the scanlines that went in. If any of those is wrong the file
/// opens in nothing, and the symptom is a corrupt-image icon rather than a stack trace.
/// </summary>
public static class PngTests
{
	public static void Run()
	{
		Section( "png: what is written is a file a reader will accept" );
		TestHeaderIsAPng();
		TestPixelsSurviveTheRoundTrip();
		TestEveryChunkCrcIsRight();
		TestABakedMapCanBeFlippedOnTheWayOut();
		TestBadInputIsRefused();
	}

	static byte[] Gradient( int w, int h )
	{
		var rgb = new byte[w * h * 3];

		for ( var y = 0; y < h; y++ )
		{
			for ( var x = 0; x < w; x++ )
			{
				var i = (y * w + x) * 3;
				rgb[i] = (byte)(x * 7 + y);
				rgb[i + 1] = (byte)(y * 3);
				rgb[i + 2] = (byte)(255 - x);
			}
		}

		return rgb;
	}

	static void TestHeaderIsAPng()
	{
		var bytes = PngWriter.ToBytes( Gradient( 9, 5 ), 9, 5 );
		var signature = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
		var ok = bytes.Length > 8;

		for ( var i = 0; ok && i < signature.Length; i++ )
			ok = bytes[i] == signature[i];

		Check( "it starts with the PNG signature", ok );

		var ihdr = Chunk( bytes, "IHDR" );

		Check( "IHDR is thirteen bytes", ihdr is { Length: 13 }, $"{ihdr?.Length}" );
		Check( "and names the size it was given", ReadBe( ihdr, 0 ) == 9 && ReadBe( ihdr, 4 ) == 5,
			$"{ReadBe( ihdr, 0 )}x{ReadBe( ihdr, 4 )}" );
		Check( "8-bit truecolour, no interlace",
			ihdr[8] == 8 && ihdr[9] == 2 && ihdr[10] == 0 && ihdr[11] == 0 && ihdr[12] == 0 );
		Check( "and it ends with IEND", Chunk( bytes, "IEND" ) is { Length: 0 } );
	}

	static void TestPixelsSurviveTheRoundTrip()
	{
		// The check that matters: inflate the IDAT and compare against what went in, scanline filter
		// bytes and all. A file that passes every header check and holds the wrong pixels is still a
		// broken bake.
		const int w = 13;
		const int h = 7;
		var rgb = Gradient( w, h );
		var bytes = PngWriter.ToBytes( rgb, w, h );

		var idat = Chunk( bytes, "IDAT" );
		var raw = Inflate( idat );

		Check( "the IDAT inflates to one filter byte per scanline plus the pixels",
			raw.Length == h * (w * 3 + 1), $"{raw.Length} bytes, expected {h * (w * 3 + 1)}" );

		var same = true;
		var o = 0;

		for ( var y = 0; y < h && same; y++ )
		{
			same &= raw[o++] == 0;

			for ( var x = 0; x < w * 3; x++ )
				same &= raw[o++] == rgb[y * w * 3 + x];
		}

		Check( "and every pixel comes back exactly as it went in", same );
	}

	static void TestEveryChunkCrcIsRight()
	{
		// A wrong CRC is the classic hand-rolled-PNG bug: most viewers refuse the file outright and
		// the ones that do not are the ones you happen to test with.
		var bytes = PngWriter.ToBytes( Gradient( 6, 6 ), 6, 6 );
		var offset = 8;
		var chunks = 0;
		var good = true;

		while ( offset + 12 <= bytes.Length )
		{
			var length = ReadBe( bytes, offset );
			var type = Encoding.ASCII.GetString( bytes, offset + 4, 4 );
			var stated = unchecked((uint)ReadBe( bytes, offset + 8 + length ));

			var actual = Crc32( bytes, offset + 4, 4 + length );
			good &= stated == actual;
			chunks++;

			offset += 12 + length;

			if ( type == "IEND" )
				break;
		}

		Check( $"all {chunks} chunks carry a correct CRC", good && chunks == 3, $"{chunks} chunks" );
		Check( "and nothing is left over after IEND", offset == bytes.Length,
			$"{bytes.Length - offset} trailing bytes" );
	}

	static void TestABakedMapCanBeFlippedOnTheWayOut()
	{
		// Row order is a convention, not a fact, and getting it wrong lights a model exactly as
		// wrongly as an inverted green channel. Both directions have to be available and the switch
		// has to do only that.
		var map = new BakedMap( 4, 3 );

		for ( var i = 0; i < map.Width * map.Height; i++ )
			map.Rgb[i * 3] = (byte)(i / map.Width);   // red = row number

		var dir = Path.Combine( Path.GetTempPath(), $"effigy-png-{Guid.NewGuid():N}" );
		Directory.CreateDirectory( dir );

		try
		{
			var upright = Path.Combine( dir, "upright.png" );
			var flipped = Path.Combine( dir, "flipped.png" );

			PngWriter.WriteFile( upright, map );
			PngWriter.WriteFile( flipped, map, flipVertically: true );

			var a = Rows( File.ReadAllBytes( upright ), 4, 3 );
			var b = Rows( File.ReadAllBytes( flipped ), 4, 3 );

			Check( "written as-is, row 0 is first", a[0] == 0 && a[2] == 2, $"{a[0]},{a[1]},{a[2]}" );
			Check( "flipped, row 0 is last", b[0] == 2 && b[2] == 0, $"{b[0]},{b[1]},{b[2]}" );
			Check( "and the file is otherwise the same size", new FileInfo( upright ).Length > 0
				&& File.ReadAllBytes( upright ).Length == File.ReadAllBytes( flipped ).Length );
		}
		finally
		{
			Directory.Delete( dir, recursive: true );
		}
	}

	static void TestBadInputIsRefused()
	{
		Check( "a buffer too small for the stated size is refused",
			Throws( () => PngWriter.ToBytes( new byte[10], 8, 8 ) ) );
		Check( "and a zero-sized image is refused",
			Throws( () => PngWriter.ToBytes( new byte[3], 0, 1 ) ) );
	}

	/// <summary>The red channel of each row, read back out of a written file.</summary>
	static int[] Rows( byte[] png, int w, int h )
	{
		var raw = Inflate( Chunk( png, "IDAT" ) );
		var rows = new int[h];

		for ( var y = 0; y < h; y++ )
			rows[y] = raw[y * (w * 3 + 1) + 1];

		return rows;
	}

	static byte[] Chunk( byte[] png, string want )
	{
		var offset = 8;

		while ( offset + 12 <= png.Length )
		{
			var length = ReadBe( png, offset );
			var type = Encoding.ASCII.GetString( png, offset + 4, 4 );

			if ( type == want )
			{
				var data = new byte[length];
				Array.Copy( png, offset + 8, data, 0, length );
				return data;
			}

			offset += 12 + length;
		}

		return null;
	}

	/// <summary>Strip the two-byte zlib header and the Adler-32, and inflate what is between.</summary>
	static byte[] Inflate( byte[] zlib )
	{
		using var input = new MemoryStream( zlib, 2, zlib.Length - 6 );
		using var deflate = new DeflateStream( input, CompressionMode.Decompress );
		using var output = new MemoryStream();

		deflate.CopyTo( output );
		return output.ToArray();
	}

	static int ReadBe( byte[] b, int o ) => (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];

	static uint Crc32( byte[] data, int offset, int length )
	{
		var c = 0xFFFFFFFFu;

		for ( var i = 0; i < length; i++ )
		{
			var x = data[offset + i];
			c = Table[(c ^ x) & 0xFF] ^ (c >> 8);
		}

		return c ^ 0xFFFFFFFFu;
	}

	static readonly uint[] Table = BuildTable();

	static uint[] BuildTable()
	{
		var table = new uint[256];

		for ( uint n = 0; n < 256; n++ )
		{
			var c = n;

			for ( var k = 0; k < 8; k++ )
				c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;

			table[n] = c;
		}

		return table;
	}

	static bool Throws( Action action )
	{
		try
		{
			action();
			return false;
		}
		catch
		{
			return true;
		}
	}
}