diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..326546f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: ["main", "pipfile-experiment"] + pull_request: + branches: ["main", "pipfile-experiment"] + +jobs: + test: + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pipenv + pipenv install --dev --skip-lock --python $(which python) + + - name: Run tests + run: | + pipenv run pytest --maxfail=1 --disable-warnings -q + + - name: Build package + run: | + pipenv run python -m build diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..538ba9f --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +build = "*" +twine = "*" +pytest = "*" + +[requires] +python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..03e8dce --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,391 @@ +{ + "_meta": { + "hash": { + "sha256": "e0932273ceead75e720b415b9885af7f9edc69cc362c85ffb8d686cfdb86b623" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.13" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": {}, + "develop": { + "build": { + "hashes": [ + "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", + "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.3.0" + }, + "certifi": { + "hashes": [ + "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", + "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + ], + "markers": "python_version >= '3.7'", + "version": "==2025.10.5" + }, + "charset-normalizer": { + "hashes": [ + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "docutils": { + "hashes": [ + "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", + "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8" + ], + "markers": "python_version >= '3.9'", + "version": "==0.22.2" + }, + "id": { + "hashes": [ + "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", + "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, + "idna": { + "hashes": [ + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + ], + "markers": "python_version >= '3.8'", + "version": "==3.11" + }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "jaraco.classes": { + "hashes": [ + "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + ], + "markers": "python_version >= '3.8'", + "version": "==3.4.0" + }, + "jaraco.context": { + "hashes": [ + "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", + "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" + ], + "markers": "python_version >= '3.8'", + "version": "==6.0.1" + }, + "jaraco.functools": { + "hashes": [ + "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", + "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294" + ], + "markers": "python_version >= '3.9'", + "version": "==4.3.0" + }, + "keyring": { + "hashes": [ + "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", + "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd" + ], + "markers": "python_version >= '3.9'", + "version": "==25.6.0" + }, + "markdown-it-py": { + "hashes": [ + "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", + "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" + ], + "markers": "python_version >= '3.10'", + "version": "==4.0.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "more-itertools": { + "hashes": [ + "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", + "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd" + ], + "markers": "python_version >= '3.9'", + "version": "==10.8.0" + }, + "nh3": { + "hashes": [ + "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", + "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", + "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", + "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", + "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", + "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", + "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", + "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", + "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", + "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", + "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", + "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", + "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", + "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", + "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", + "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", + "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", + "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", + "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", + "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", + "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", + "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", + "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", + "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", + "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", + "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a" + ], + "markers": "python_version >= '3.8'", + "version": "==0.3.2" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pyproject-hooks": { + "hashes": [ + "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", + "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pytest": { + "hashes": [ + "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + }, + "pywin32-ctypes": { + "hashes": [ + "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", + "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755" + ], + "markers": "python_version >= '3.6'", + "version": "==0.2.3" + }, + "readme-renderer": { + "hashes": [ + "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", + "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1" + ], + "markers": "python_version >= '3.9'", + "version": "==44.0" + }, + "requests": { + "hashes": [ + "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", + "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" + ], + "markers": "python_version >= '3.9'", + "version": "==2.32.5" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.0" + }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "rich": { + "hashes": [ + "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", + "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==14.2.0" + }, + "twine": { + "hashes": [ + "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", + "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==6.2.0" + }, + "urllib3": { + "hashes": [ + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + } + } +} diff --git a/README.md b/README.md index 6022e0e..2031f02 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,291 @@ -# Python Package Exercise +[![CI](https://github.com/swe-students-fall2025/3-python-package-team_cascade/actions/workflows/ci.yml/badge.svg?branch=pipfile-experiment)](https://github.com/swe-students-fall2025/3-python-package-team_cascade/actions/workflows/ci.yml) -An exercise to create a Python package, build it, test it, distribute it, and use it. See [instructions](./instructions.md) for details. +# 🐍 SsstudyPet + +**SsstudyPet** is a Python package that provides an interactive command-line application to gamify your study sessions by letting you raise and care for a virtual ball python. The more you study, the more your snake grows! Track your study time, earn coins, feed your python, and watch it level up as you build consistent study habits. + +## Features + +- **📚 Study Session Tracking**: Start and end study sessions with automatic time tracking +- **✅ Task-Based Rewards**: Set tasks at the start of each session and earn 75 coins per completed task +- **� Virtual Ball Python**: Your snake levels up based on total study hours (1 level per 5 hours) +- **� Mood System**: Keep your python happy by logging in regularly and feeding it +- **💰 Earn Coins**: Complete tasks to earn coins that can be used to purchase food for your python +- **🐭 Feed Your Python**: Choose from various prey (mouse, rat, quail, rabbit, cricket) to boost mood +- **📈 Progress Tracking**: Monitor your study streaks, total study time, and python status +- **🔄 Auto-save**: Sessions automatically save on exit or interruption +- **🎮 Interactive Menu**: Easy-to-use CLI menu for all features + +## Team Members + +- [Catherine Yu](https://github.com/catherineyu2014) +- [Leo Fu](https://github.com/LeoFYH) +- [JunHao Chen](https://github.com/JunHaoChen16) +- [Majo Salgado](https://github.com/mariajsalgadoq) +- [Zeba Shafi](https://github.com/Zeba-Shafi) + +## Installation + +Install from PyPI: + +```bash +pip install study-pet +``` + +Or install from source: + +```bash +git clone https://github.com/swe-students-fall2025/3-python-package-team_cascade.git +cd team_cascade +pipenv install --dev +``` + +## Using SsstudyPet in Your Code + +You can import and use SsstudyPet's functions in your own Python programs. Here's documentation for all available functions: + +### Study Session Management + +```python +from study_pet.tracker import start_session, end_session + +# Start a study session with task planning +start_session() +# Prompts: "How many tasks do you plan to complete this session?" +# Sets session_tasks_planned and records start time + +# End a study session with task completion +end_session() +# Prompts: "How many tasks did you complete this session?" +# Rewards 75 coins per completed task +# Updates total study time and saves progress +``` + +### Pet Care Functions + +```python +from study_pet.pet import feed_pet, rename_pet, set_morph, get_encouragement + +# Feed your ball python (costs coins, boosts mood) +feed_pet("mouse") # Costs 50 coins, +10 mood +# ... (rest of feed_pet examples) ... + +# Rename your ball python +rename_pet("Slithers") + +# Set your ball python's morph (color pattern) +set_morph("Banana") +# ... (rest of set_morph examples) ... + +# Get an encouraging phrase from your pet +encouragement = get_encouragement() +print(encouragement) # Outputs: 🐍 Guido says: "Sssstay focused!" + +# Get your ball python's current status +status = get_status() +print(status) # Returns formatted status string +``` + +### Status and Data Functions + +```python +from study_pet.pet import get_status +from study_pet.data_manager import load_state, save_state, reset_data + +# Get your ball python's current status +status = get_status() +print(status) # Returns formatted status string with all pet info + +# Load current saved data +state = load_state() +print(f"Level: {state['level']}") +print(f"Coins: {state['coins']}") +print(f"Morph: {state['morph']}") + +# Manually save data (normally done automatically) +save_state(state) + +# Reset all data (use with caution!) +reset_data() +``` + +### Example Program + +See our complete example program that demonstrates all functions: [example.py](https://github.com/swe-students-fall2025/3-python-package-team_cascade/blob/main/example.py) + +```python +# example.py - Complete demonstration of SsstudyPet +from study_pet.tracker import start_session, end_session +from study_pet.pet import feed_pet, rename_pet, set_morph, get_status, get_encouragement +from study_pet.data_manager import load_state + +# Start a study session +print("Starting study session...") +start_session() # Will prompt for task count + +# Do some studying... (simulated) +print("\n📚 Studying...\n") + +# End the session and earn rewards +end_session() # Will prompt for completed tasks + +# Get encouragement +print("\n" + get_encouragement()) + +# Check your ball python's status +print("\n" + get_status()) + +# Name your python +rename_pet("Monty") +print("\n🐍 Your ball python is now named Monty!") + +# Set a morph +set_morph("Banana") +print("\n✨ Your python is now a beautiful Banana morph!") + +# Feed your python +feed_pet("mouse") +print("\n🐭 Fed your python a mouse!") + +# Check final status +print("\n" + get_status()) + +# Access raw data +state = load_state() +print(f"\n💰 Total coins: {state['coins']}") +print(f"⏱️ Total study time: {state['total_minutes']} minutes") +``` + +## Contributing to SsstudyPet + +Want to help make SsstudyPet better? Here's how to set up your development environment: + +### Prerequisites + +- Python 3.10 or higher (Python 3.13 recommended although we used 3.9) +- pipenv for dependency management + +### Setup Instructions + +1. **Clone the repository** + ```bash + git clone https://github.com/swe-students-fall2025/3-python-package-team_cascade.git + cd 3-python-package-team_cascade-4 + ``` + +2. **Install pipenv** (if not already installed) + ```bash + pip install pipenv + ``` + +3. **Set up the virtual environment and install dependencies** + ```bash + # Install all dependencies including dev dependencies + pipenv install --dev + + # This installs: + # - pytest (for testing) + # - build (for building the package) + # - twine (for publishing to PyPI) + ``` + +4. **Activate the virtual environment** + ```bash + pipenv shell + ``` + +5. **Run the tests** + ```bash + # Run all tests + pytest tests/ -v + + # Run tests with coverage + pytest tests/ --cov=study_pet --cov-report=html + + # Run specific test file + pytest tests/test_actions.py -v + ``` + +6. **Build the package** + ```bash + # Build distribution files + python -m build + + # This creates dist/ directory with: + # - .tar.gz (source distribution) + # - .whl (wheel distribution) + ``` + +7. **Test the package locally** + ```bash + # Install in editable mode for development + pip install -e . + + # Run the CLI + study-pet menu + ``` + +### Development Workflow + +1. Create a new feature branch: + ```bash + git checkout -b feature/your-feature-name + ``` + +2. Make your changes and test thoroughly: + ```bash + pytest tests/ -v + ``` + +3. Commit your changes: + ```bash + git add . + git commit -m "Add your descriptive commit message" + ``` + +4. Push to GitHub and create a pull request: + ```bash + git push origin feature/your-feature-name + ``` + +5. Wait for CI tests to pass (GitHub Actions automatically runs tests) + +### Project Structure + +``` +study_pet/ +├── __init__.py # Package initialization +├── __main__.py # CLI entry point +├── data_manager.py # Data persistence functions +├── tracker.py # Study session tracking +└── pet/ + ├── __init__.py # Pet module exports + ├── core.py # Pet status and leveling + └── actions.py # Pet interaction functions +tests/ +├── test_actions.py # Tests for pet actions +├── test_core.py # Tests for pet core functions +├── test_data_manager.py # Tests for data management +├── test_tracker.py # Tests for session tracking +├── test_main.py # Tests for CLI commands +└── test_morph.py # Tests for morph system +``` + +### Running Specific Tests + +```bash +# Test specific functionality +pytest tests/test_morph.py::test_set_morph_valid -v +pytest tests/test_tracker.py -v +pytest tests/test_actions.py::test_feed_pet -v + +# Test with output +pytest tests/ -v -s + +# Generate coverage report +pytest tests/ --cov=study_pet --cov-report=term-missing +``` + +## PyPI Package + +This package is available on PyPI: [study-pet](https://test.pypi.org/project/study-pet/0.1.0/)) diff --git a/example.py b/example.py new file mode 100644 index 0000000..f2a2aab --- /dev/null +++ b/example.py @@ -0,0 +1,121 @@ +""" +example.py - Complete demonstration of SsstudyPet + +This example program demonstrates all the functions available in the +SsstudyPet package. It shows how to: +- Start and end study sessions with task tracking +- Feed your ball python +- Rename your ball python +- Set morphs (color patterns) +- Check status and access saved data +""" + +from study_pet.tracker import start_session, end_session +from study_pet.pet import feed_pet, rename_pet, set_morph, get_status, get_encouragement +from study_pet.data_manager import load_state + +def main(): + print("=" * 60) + print("🐍 Welcome to SsstudyPet Example Program 🐍") + print("=" * 60) + print("\nThis program demonstrates all SsstudyPet functions.\n") + + # Start a study session + print("📚 STARTING A STUDY SESSION") + print("-" * 60) + start_session() # Will prompt for task count + + print("\n✏️ Imagine you're studying now...") + print(" (In real use, you'd close the terminal and study!)") + input("\n Press Enter to end your study session...") + + # End the session and earn rewards + print("\n🎉 ENDING STUDY SESSION") + print("-" * 60) + end_session() # Will prompt for completed tasks + + print("\n📣 GETTING ENCOURAGEMENT") + print("-" * 60) + print(get_encouragement()) + + # Check your ball python's status + print("\n📊 CHECKING YOUR BALL PYTHON'S STATUS") + print("-" * 60) + print(get_status()) + + # Rename your python + print("\n✏️ RENAMING YOUR BALL PYTHON") + print("-" * 60) + new_name = input("What would you like to name your ball python? (or press Enter for 'Monty'): ").strip() + if not new_name: + new_name = "Monty" + rename_pet(new_name) + print(f"\n🐍 Your ball python is now named {new_name}!") + + # Set a morph (color pattern) + print("\n🎨 SETTING A MORPH (COLOR PATTERN)") + print("-" * 60) + print("Available morphs:") + print(" 1. Banana 2. Pastel 3. Pied 4. Clown") + print(" 5. Mojave 6. Cinnamon 7. Albino 8. Blue Eyed Leucistic") + print(" 9. GHI 10. Spider") + + morph_choice = input("\nEnter a morph name (or press Enter for 'Banana'): ").strip() + if not morph_choice: + morph_choice = "Banana" + set_morph(morph_choice) + print(f"\n✨ Your python is now a beautiful {morph_choice} morph!") + + # Check status again to see the changes + print("\n📊 UPDATED STATUS") + print("-" * 60) + print(get_status()) + + # Feed your python + print("\n🍽️ FEEDING YOUR BALL PYTHON") + print("-" * 60) + print("Available prey:") + print(" - cricket (30 coins, +5 mood)") + print(" - mouse (50 coins, +10 mood)") + print(" - rat (80 coins, +15 mood)") + print(" - quail (130 coins, +25 mood)") + print(" - rabbit (150 coins, +30 mood)") + + # Check current coins + state = load_state() + print(f"\nYou currently have {state.get('coins', 0)} coins.") + + prey_choice = input("\nWhat would you like to feed your python? (or press Enter for 'mouse'): ").strip().lower() + if not prey_choice: + prey_choice = "mouse" + + feed_pet(prey_choice) + print(f"\n🐭 Fed your python a {prey_choice}!") + + # Show final status + print("\n📊 FINAL STATUS") + print("-" * 60) + print(get_status()) + + # Access and display raw data + print("\n📈 RAW DATA") + print("-" * 60) + state = load_state() + print(f"💰 Total coins: {state.get('coins', 0)}") + print(f"⏱️ Total study time: {state.get('total_minutes', 0)} minutes") + print(f"📅 Current streak: {state.get('streak', 0)} days") + print(f"🎯 Level: {state.get('level', 1)}") + print(f"😊 Mood: {state.get('mood', 0)}") + print(f"🎨 Morph: {state.get('morph', 'Banana')}") + print(f"📝 Session tasks planned: {state['session_tasks_planned']}") + print(f"✅ Session tasks completed: {state['session_tasks_completed']}") + + print("\n" + "=" * 60) + print("🎉 Example program complete!") + print("=" * 60) + print("\nTo use SsstudyPet normally, run: study-pet menu") + print("or use individual commands like: study-pet start, study-pet status, etc.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3226b9f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "study-pet" +version = "0.1.0" +description = "A Python package to gamify your study sessions by raising a virtual ball python" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "Catherine Yu", email = "catherine@example.com"}, + {name = "Leo Fu", email = "leo@example.com"}, + {name = "JunHao Chen", email = "junhao@example.com"}, + {name = "Majo Salgado", email = "majo@example.com"}, + {name = "Zeba Shafi", email = "zeba@example.com"}, +] +keywords = ["study", "productivity", "gamification", "pet", "cli", "ball-python"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Education", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Education", +] + +[project.urls] +Homepage = "https://github.com/swe-students-fall2025/3-python-package-team_cascade" +Repository = "https://github.com/swe-students-fall2025/3-python-package-team_cascade" +Issues = "https://github.com/swe-students-fall2025/3-python-package-team_cascade/issues" + +[project.scripts] +study-pet = "study_pet.__main__:main" + +[tool.setuptools.packages.find] +include = ["study_pet*"] +exclude = ["tests*"] diff --git a/study_pet/__init__.py b/study_pet/__init__.py new file mode 100644 index 0000000..d583678 --- /dev/null +++ b/study_pet/__init__.py @@ -0,0 +1,11 @@ +from .tracker import start_session, end_session +from .pet.core import get_status +from .data_manager import reset_state as _reset_state + +__all__ = ["start_session", "end_session", "get_status", "reset_pet"] + + +def reset_pet(): + """Resets all pet data and progress.""" + _reset_state() + print("🥚 Your ball python has hatched anew! All progress reset. 🐍") diff --git a/study_pet/__main__.py b/study_pet/__main__.py new file mode 100644 index 0000000..896df9c --- /dev/null +++ b/study_pet/__main__.py @@ -0,0 +1,122 @@ +from . import start_session, end_session, get_status, reset_pet +from .data_manager import load_state, save_state +from .pet import rename_pet, feed_pet, set_morph, check_daily_mood_decay +import argparse +import study_pet.tracker as tracker + + +def main(): + parser = argparse.ArgumentParser(description="🐍 SsstudyPet") + parser.add_argument( + "command", + nargs="?", + default="menu", + help="Available commands: start, end, status, feed, rename, morph, menu", + ) + parser.add_argument( + "arg", + nargs="?", + default=None, + help="Optional argument for feed (food name), rename (new name), or morph (morph type)", + ) + args = parser.parse_args() + + check_daily_mood_decay() + + if args.command == "start": + start_session() + elif args.command == "end": + end_session() + elif args.command == "status": + print(get_status()) + elif args.command == "feed": + feed_pet(args.arg) + elif args.command == "rename": + rename_pet(args.arg) + elif args.command == "morph": + set_morph(args.arg) + elif args.command == "menu": + main_menu() + else: + print("Unknown command. Use: start | end | status | feed [food] | rename [name] | morph [type] | menu") + + +def actions_menu(): + """Submenu for all pet-related actions.""" + while True: + print("\n🐍 Actions Menu:") + print("1. Feed your ball python") + print("2. Back") + + choice = input("\nSelect an option (1–2): ").strip() + + if choice == "1": + feed_pet() + elif choice == "2": + break + else: + print("Invalid option. Try again.") + + +def settings_menu(): + """Submenu for settings and info.""" + while True: + print("\n⚙️ Settings Menu:") + print("1. Check ball python status") + print("2. Rename your ball python") + print("3. Change ball python morph") + print("4. Reset all data") + print("5. Back") + + choice = input("\nSelect an option (1–5): ").strip() + + if choice == "1": + print(get_status()) + elif choice == "2": + rename_pet() + elif choice == "3": + set_morph() + elif choice == "4": + confirm = input( + "This will reset all progress. Type 'byebye' to confirm " + ).lower() + if confirm == "byebye": + reset_pet() + elif choice == "5": + break + else: + print("Invalid option. Try again.") + + +def main_menu(): + """Main entry menu.""" + while True: + print("\n� Welcome to SsstudyPet 🐍\n") + print("1. Start studying ") + print("2. End session") + print("3. Actions") + print("4. Settings") + print("5. Close Menu (return to terminal)") + print("6. Exit (Close SsstudyPet to terminal)") + + choice = input("\nSelect an option (1–6): ").strip() + if choice == "1": + start_session() + elif choice == "2": + end_session() + elif choice == "3": + actions_menu() + elif choice == "4": + settings_menu() + elif choice == "5": + tracker.manual_close = True + break + elif choice == "6": + tracker.manual_close = False + break + else: + print("Invalid option. Try again.") + + +if __name__ == "__main__": + main() diff --git a/study_pet/data_manager.py b/study_pet/data_manager.py new file mode 100644 index 0000000..91dbc7c --- /dev/null +++ b/study_pet/data_manager.py @@ -0,0 +1,57 @@ +import json +import os + +DATA_DIR = os.path.expanduser("~/.study_pet") +DATA_PATH = os.path.join(DATA_DIR, "data.json") + +default_state = { + "name": "Guido", + "level": 1, + "experience": 0.0, + "total_study_time": 0.0, + "last_session_start": None, + "last_study_date": None, + "mood": 100, + "streak_days": 0, + "money": 0, + "last_feed_date": None, + "last_open_date": None, + "session_tasks_planned": 0, + "session_tasks_completed": 0, + "morph": "Normal/Wild Type", +} + + +def ensure_data_dir(): + if not os.path.exists(DATA_DIR): + os.makedirs(DATA_DIR, exist_ok=True) + + +def load_state(): + """ """ + ensure_data_dir() + if not os.path.exists(DATA_PATH): + save_state(default_state) + try: + with open(DATA_PATH, "r") as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + save_state(default_state) + return default_state.copy() + + +def save_state(state: dict): + ensure_data_dir() + with open(DATA_PATH, "w") as f: + json.dump(state, f, indent=4) + + +def reset_state(): + save_state(default_state.copy()) + + +if __name__ == "__main__": + print("📁 Checking data directory:", DATA_DIR) + reset_state() + print("✅ Pet data initialized at:", DATA_PATH) + print("📂 Current state:", load_state()) diff --git a/study_pet/pet/__init__.py b/study_pet/pet/__init__.py new file mode 100644 index 0000000..c5afe8c --- /dev/null +++ b/study_pet/pet/__init__.py @@ -0,0 +1,10 @@ +from .core import get_status, check_daily_mood_decay +from .actions import rename_pet, feed_pet, set_morph + +__all__ = [ + "get_status", + "rename_pet", + "feed_pet", + "set_morph", + "check_daily_mood_decay", +] diff --git a/study_pet/pet/actions.py b/study_pet/pet/actions.py new file mode 100644 index 0000000..c12ba3d --- /dev/null +++ b/study_pet/pet/actions.py @@ -0,0 +1,292 @@ +""" +Pet interaction features (rename, feed, talk, etc.) +""" + +from datetime import datetime, timedelta +import time +from ..data_manager import load_state, save_state +import random + + +def rename_pet(new_name: str = None): + """ + Renames the ball python and saves to state. + If no new_name is passed, will ask user input interactively. + """ + state = load_state() + old_name = state.get("name", "Unnamed") + + if not new_name: + print(f"\n🐍 Current name: {old_name}") + new_name = input("Enter new name for your ball python: ").strip() + + if not new_name: + print("Name cannot be empty.") + return + + state["name"] = new_name + save_state(state) + print(f"🐍 Ball python name changed to '{new_name}'!\n") + + +def feed_pet(food_name: str = None): + """ + Feed your ball python with food purchased using money. + Each food has different cost and mood increase. + + Args: + food_name: Optional food name (mouse, rat, quail, rabbit, cricket). + If not provided, will show interactive menu. + """ + state = load_state() + name = state.get("name", "Monty") + money = state.get("money", 0) + mood = state.get("mood", 100) + + # Ball python appropriate foods + foods = { + "mouse": {"cost": 50, "mood": 8, "emoji": "🐭", "msg": "Gulp! A tasty snack!"}, + "rat": { + "cost": 80, + "mood": 15, + "emoji": "🐀", + "msg": "Mmm, satisfying meal!", + }, + "quail": {"cost": 130, "mood": 20, "emoji": "🐦", "msg": "Exotic and delicious!"}, + "rabbit": {"cost": 150, "mood": 25, "emoji": "🐰", "msg": "What a feast!"}, + "cricket": {"cost": 30, "mood": 5, "emoji": "🦗", "msg": "A little crunchy treat!"}, + "custom": {"cost": 80, "mood": 10, "emoji": "🍽️", "msg": "Slither slither, nom nom!"}, + } + + # If food_name is provided, use it directly + if food_name: + food_name = food_name.lower().strip() + if food_name not in foods: + print(f"Invalid food name: {food_name}") + print(f"Available foods: {', '.join([f for f in foods.keys() if f != 'custom'])}") + return + + selected = food_name + food = foods[selected] + display_name = selected + + # check balance + if money < food["cost"]: + print(f"Not enough coins! {food['cost']} needed, but you have {money}.") + return + + # apply effects + money -= food["cost"] + new_mood = min(100, mood + food["mood"]) + state["money"] = money + state["mood"] = new_mood + state["last_feed_date"] = datetime.now().strftime("%Y-%m-%d") + save_state(state) + + print(f"\n{food['emoji']} You fed {name} the ball python a {display_name}!") + print(food["msg"]) + print(f"🐍 Mood increased to {new_mood}/100.") + print(f"💰 Remaining balance: {money} coins.\n") + return + + # Interactive menu mode (original behavior) + print(f"\n🐍 {name}'s current mood: {mood}/100") + print(f"💰 Current balance: {money} coins") + print("Choose something to feed your ball python:") + print("1. Mouse 🐭 (Cost: 50 | +8 mood)") + print("2. Rat 🐀 (Cost: 80 | +15 mood)") + print("3. Quail 🐦 (Cost: 130 | +20 mood)") + print("4. Rabbit 🐰 (Cost: 150 | +25 mood)") + print("5. Cricket 🦗 (Cost: 30 | +5 mood)") + print("6. Custom food ✏️ (Cost: 80 | +10 mood)") + print("7. Return") + choice = input("Select (1–7): ").strip() + + mapping = { + "1": "mouse", + "2": "rat", + "3": "quail", + "4": "rabbit", + "5": "cricket", + "6": "custom", + } + if choice == "7": + return + if choice not in mapping: + print(" Invalid choice.") + return + + selected = mapping[choice] + food = foods[selected] + + # handle custom name + if selected == "custom": + custom_name = input("Enter your custom food name: ").strip() or "mystery prey" + food["msg"] = f"🐍 {name} slithered over and ate your {custom_name}!" + display_name = custom_name + else: + display_name = selected + + # check balance + if money < food["cost"]: + print(f"Not enough coins! {food['cost']} needed, but you have {money}.") + return + + # apply effects + money -= food["cost"] + new_mood = min(100, mood + food["mood"]) + state["money"] = money + state["mood"] = new_mood + state["last_feed_date"] = datetime.now().strftime("%Y-%m-%d") + save_state(state) + + print(f"\n{food['emoji']} You fed {name} the ball python a {display_name}!") + print(food["msg"]) + print(f"🐍 Mood increased to {new_mood}/100.") + print(f"💰 Remaining balance: {money} coins.\n") + + +def set_morph(morph_name: str = None): + """ + Set the ball python's morph (color pattern). + If no morph_name is passed, will show interactive menu. + """ + state = load_state() + current_morph = state.get("morph", "Normal/Wild Type") + + # Available morphs with descriptions + morphs = { + "1": { + "name": "Normal/Wild Type", + "desc": "Natural coloration with brown and tan patterns" + }, + "2": { + "name": "Albino", + "desc": "Yellow, orange, and white with red/pink eyes" + }, + "3": { + "name": "Pastel", + "desc": "Brightened colors with lighter yellows" + }, + "4": { + "name": "Spider", + "desc": "Bold, high-contrast web-like appearance" + }, + "5": { + "name": "Mojave", + "desc": "Lighter sides with prominent dorsal stripe" + }, + "6": { + "name": "Pinstripe", + "desc": "Clean, thin stripe down the spine" + }, + "7": { + "name": "Clown", + "desc": "Distinctive head pattern and elongated blotches" + }, + "8": { + "name": "Banana", + "desc": "Bright yellow and purple/lavender coloring" + }, + "9": { + "name": "Black Pastel", + "desc": "Darkened coloration with good contrast" + }, + "10": { + "name": "Cinnamon", + "desc": "Rich brown and caramel tones" + }, + "11": { + "name": "Custom", + "desc": "Create your own unique morph!" + } + } + # If morph_name is provided directly + if morph_name: + morph_name = morph_name.strip() + # Check if it matches any morph name + found = False + for morph_data in morphs.values(): + if morph_data["name"].lower() == morph_name.lower(): + state["morph"] = morph_data["name"] + save_state(state) + print(f"🐍 Your ball python's morph is now: {morph_data['name']}!") + print(f" {morph_data['desc']}") + found = True + break + + if not found: + print(f"Invalid morph name: {morph_name}") + print("Available morphs: " + ", ".join([m["name"] for m in morphs.values() if m["name"] != "Custom"])) + return + + # Interactive menu mode + print(f"\n🐍 Current morph: {current_morph}") + print("\n✨ Choose your ball python's morph:\n") + + for key, morph_data in morphs.items(): + print(f"{key:>2}. {morph_data['name']:<20} - {morph_data['desc']}") + + print(f"\n{len(morphs) + 1}. Cancel") + + choice = input(f"\nSelect (1–{len(morphs) + 1}): ").strip() + + if choice == str(len(morphs) + 1): + print("Morph selection cancelled.") + return + + if choice not in morphs: + print("❌ Invalid choice.") + return + + # Handle custom morph option + if choice == "11": + custom_morph = input("\n✨ Enter your custom morph name: ").strip() + if not custom_morph: + print("❌ Morph name cannot be empty.") + return + + state["morph"] = custom_morph + save_state(state) + print(f"\n🐍 Your ball python's morph is now: {custom_morph} (custom)!") + print("✨ Your python looks unique and beautiful!\n") + return + + selected_morph = morphs[choice]["name"] + state["morph"] = selected_morph + save_state(state) + + print(f"\n✨ Your ball python's morph is now: {selected_morph}!") + print(f" {morphs[choice]['desc']}") + print("🐍 Your python looks beautiful!\n") + +ENCOURAGEMENT_PHRASES = [ + "You're doing sssso great! Keep up the good work!", + "Every sssstudy ssssession makes you sssmarter!", + "Don't give up! Jussst a little more effort!", + "I believe in you! You've got thisss!", + "Sssslithering sssuccess is just around the corner!", + "Sssstay focused! You're on a roll!", + "Look at you, being sssso productive!", + "Your dedication is insssspiring!", + "Jussst think of all the coinsss you'll earn!", + "Keep going! Your future ssself will thank you.", +] + +def get_encouragement() -> str: + """ + Returns a random encouraging phrase from the pet. + + The phrase is personalized with the pet's name if it has one. + + Returns: + A string containing a formatted encouragement message. + """ + state = load_state() + # Get the pet's name, or use the default + pet_name = state.get("name", "Guido") + + # Pick a random phrase from the list + phrase = random.choice(ENCOURAGEMENT_PHRASES) + + return f"🐍 {pet_name} says: \"{phrase}\"" diff --git a/study_pet/pet/core.py b/study_pet/pet/core.py new file mode 100644 index 0000000..4ceca22 --- /dev/null +++ b/study_pet/pet/core.py @@ -0,0 +1,193 @@ +""" +study_pet/pet/core.py +------------------------------------------- +Core logic for SsstudyPet: +Handles leveling, experience, and status updates +based on total study time for your ball python. + +Design principles: +- update_pet(): true update (writes to persistent JSON) +- get_status(): live preview (uses current session time if any, no write) +""" + +from ..data_manager import load_state, save_state +from datetime import datetime +import time + + +def _calculate_level_exp(total_hours: float): + """ + Convert total study hours to (level, exp). + + Level = floor(total_hours / 5) + 1 + EXP = (total_hours % 5) * 20 + (Every 5 hours = +1 level, 100 EXP = level up) + """ + level = int(total_hours // 5) + 1 + exp = (total_hours % 5) * 20 + return level, exp + + +# called when session ends +def update_pet(): + """ + Performs a *true* update of the pet’s level and experience, + writing the results to persistent storage. + """ + state = load_state() + total_time = state.get("total_study_time", 0.0) + prev_level = state.get("level", 1) + + new_level, new_exp = _calculate_level_exp(total_time) + + if new_level > prev_level: + print(f"🐍 {state['name']} the ball python leveled up! {prev_level} → {new_level}") + + # Update state values + state["level"] = new_level + state["experience"] = new_exp + state["last_study_date"] = datetime.now().strftime("%Y-%m-%d") + + save_state(state) + return state + + +# shows real time data +def get_status(): + """ + Does NOT modify the JSON file. + """ + state = load_state() + total_time = state.get("total_study_time", 0.0) + last_start = state.get("last_session_start", None) + + # If currently studying, include elapsed time + if last_start is not None: + elapsed = (time.time() - last_start) / 3600 + total_time += elapsed + else: + elapsed = 0.0 + + # compute simulate level/exp + level, exp = _calculate_level_exp(total_time) + + name = state.get("name", "Unnamed") + mood = state.get("mood", 100) + money = state.get("money", 0) + morph = state.get("morph", "Normal/Wild Type") + + streak = state.get("streak_days", 0) + last_study = state.get("last_study_date", "N/A") + + studying = "Studying now" if last_start else " Idle" + + # Ball python mood descriptions + if mood >= 80: + mood_status = "Slithering happily!" + elif mood >= 60: + mood_status = "Coiled and content." + elif mood >= 40: + mood_status = "A bit sluggish..." + elif mood >= 20: + mood_status = "Needs feeding soon!" + else: + mood_status = "Very lethargic!" + + status = ( + f"\n🐍 Ball Python Status 🐍\n" + f"--------------------------------\n" + f"{name} the Ball Python\n" + f"Morph: {morph}\n" + f"Level: {level} ({exp:.0f} EXP)\n" + f"Total Study Time: {total_time:.2f} hrs (+{elapsed:.2f}h current)\n" + f"Last Study: {last_study}\n" + f"\nMood: {mood}/100 — {mood_status}\n" + f"Money: {money} coins\n" + f"Streak: {streak} days\n" + f"\nSession: {studying}\n" + f"--------------------------------" + ) + + return status + + +def check_daily_mood_decay(): + """ + Called at program startup. + Decreases pet mood based on days since last login or last feeding. + """ + state = load_state() + + last_open_str = state.get("last_open_date") + last_feed_str = state.get("last_feed_date") + mood = state.get("mood", 100) + + today = datetime.now().date() + last_open = None + last_feed = None + + # convert stored dates + if last_open_str: + try: + last_open = datetime.strptime(last_open_str, "%Y-%m-%d").date() + except ValueError: + last_open = today + else: + last_open = today + + if last_feed_str: + try: + last_feed = datetime.strptime(last_feed_str, "%Y-%m-%d").date() + except ValueError: + last_feed = today + + # calculate days passed since last open + days_passed = (today - last_open).days + if days_passed <= 0: + # same day login, no decay + state["last_open_date"] = today.strftime("%Y-%m-%d") + save_state(state) + return + + # base decay by days + if days_passed == 1: + decay = 5 + elif days_passed == 2: + decay = 15 + elif days_passed == 3: + decay = 35 + elif days_passed == 4: + decay = 60 + else: + # 5 or more days + decay = 999 # will drop to 0 anyway + + # extra penalty if not fed yesterday + if last_feed is None or (today - last_feed).days >= 1: + decay += 10 + + # apply decay + new_mood = max(0, mood - decay) + + # update state + state["mood"] = new_mood + state["last_open_date"] = today.strftime("%Y-%m-%d") + save_state(state) + + # feedback message + if decay > 0: + print( + f"\nIt's been {days_passed} day(s) since you last visited." + f"\nYour pet’s mood decreased by {decay} → now {new_mood}/100 💖" + ) + + if new_mood == 0: + print("Your ball python is very lethargic... please feed it soon! 🐍") + + return new_mood + + +# Manual test +if __name__ == "__main__": + print("🔁 Checking pet status preview...") + print(get_status()) diff --git a/study_pet/tracker.py b/study_pet/tracker.py new file mode 100644 index 0000000..d05b573 --- /dev/null +++ b/study_pet/tracker.py @@ -0,0 +1,143 @@ +""" +Tracks study sessions for SsstudyPet. + +Responsible for: +- Starting and ending sessions +- Updating total study time +- Triggering ball python updates (level, exp) +""" + +import atexit +import signal +import time +from datetime import datetime +from .data_manager import load_state, save_state +from .pet.core import update_pet + + +def start_session(): + """ + Begins a study session. + If a session is already active, it will not start a new one. + """ + state = load_state() + + if state.get("last_session_start"): + print("A study session is already active.") + return + + # Ask for number of tasks to complete + while True: + try: + num_tasks = input("How many tasks do you plan to complete this session? ") + num_tasks = int(num_tasks) + if num_tasks < 0: + print("Please enter a positive number.") + continue + break + except ValueError: + print("Please enter a valid number.") + + state["last_session_start"] = time.time() + state["session_tasks_planned"] = num_tasks + state["session_tasks_completed"] = 0 + save_state(state) + print(f"📘 Study session started at {datetime.now().strftime('%H:%M:%S')}") + print(f"📝 Tasks planned: {num_tasks}") + + +def end_session(): + """ + Ends a study session and updates total study time. + Also triggers a pet level/exp update and rewards coins for completed tasks. + """ + state = load_state() + start_time = state.get("last_session_start", None) + + if not start_time: + print("⚠️ No active study session found.") + return + + # Calculate elapsed time in hours + end_time = time.time() + elapsed_hours = (end_time - start_time) / 3600 + + # Ask for completed tasks + tasks_planned = state.get("session_tasks_planned", 0) + if tasks_planned > 0: + print(f"\n📝 You planned to complete {tasks_planned} task(s).") + while True: + try: + tasks_completed = input("How many tasks did you complete? ") + tasks_completed = int(tasks_completed) + if tasks_completed < 0: + print("Please enter a positive number.") + continue + if tasks_completed > tasks_planned: + confirm = input(f"You completed more than planned! Confirm {tasks_completed} tasks? (y/n) ").lower() + if confirm != 'y': + continue + break + except ValueError: + print("Please enter a valid number.") + + # Reward coins for completed tasks + coins_per_task = 75 + coins_earned = tasks_completed * coins_per_task + state["money"] = state.get("money", 0) + coins_earned + state["session_tasks_completed"] = tasks_completed + + print(f"✅ You completed {tasks_completed} task(s)!") + print(f"💰 Earned {coins_earned} coins! (Total: {state['money']} coins)") + + # Update totals + state["total_study_time"] += elapsed_hours + state["last_session_start"] = None + state["last_study_date"] = datetime.now().strftime("%Y-%m-%d") + state["session_tasks_planned"] = 0 + + save_state(state) + print(f"\n⏱️ Study session ended. Duration: {elapsed_hours:.2f} hours") + + # Trigger pet update + update_pet() + print("🐍 Ball python data updated!") + + +def reset_sessions(): + """ + For testing or restarting — clears active session flag. + """ + state = load_state() + state["last_session_start"] = None + save_state(state) + print("Session reset complete.") + + +def get_total_time(): + """Returns the total study time stored in JSON (for tests).""" + state = load_state() + return state.get("total_study_time", 0.0) + + +# Manual test +if __name__ == "__main__": + print("Study Tracker CLI") + print("1. start_session()") + print("2. end_session()") + print("3. reset_sessions()") + +manual_close = False + + +def _auto_end_session(*args): + state = load_state() + if state.get("last_session_start") and not manual_close: + print("\n Auto-saving your progress...") + end_session() + + +# autosave on exit or interrupt +atexit.register(_auto_end_session) +signal.signal(signal.SIGINT, _auto_end_session) +signal.signal(signal.SIGTERM, _auto_end_session) diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..b09d450 --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,234 @@ +import sys, os, time, pytest, builtins +from datetime import datetime + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from study_pet.pet.actions import rename_pet, feed_pet +from study_pet.data_manager import load_state, save_state, reset_state + + +@pytest.fixture(autouse=True) +def clean_state(): + reset_state() + yield + reset_state() + + +# rename_pet(): correct +def test_rename_pet_interactive(monkeypatch): + monkeypatch.setattr(builtins, "input", lambda _: "Fluffy") + rename_pet() + state = load_state() + assert state["name"] == "Fluffy" + +# rename_pet(): invalid input +def test_rename_pet_empty(monkeypatch, capsys): + # name should be unchanged if user input is empty + state = load_state() + state["name"] = "Fluffy" + save_state(state) + + monkeypatch.setattr(builtins, "input", lambda _: "") + rename_pet() + + captured = capsys.readouterr().out + new_state = load_state() + + + assert "Name cannot be empty." in captured + assert new_state["name"] == "Fluffy" + +# rename_pet(): with parameter +def test_rename_pet_with_parameter(capsys): + state = load_state() + state["name"] = "OldName" + save_state(state) + + rename_pet("NewName") + + new_state = load_state() + captured = capsys.readouterr().out + assert new_state["name"] == "NewName" + assert "🐍 Ball python name changed to 'NewName'!" in captured + + +# feed_pet(): correct case +def test_feed_pet_increases_mood(monkeypatch): + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + inputs = iter(["1"]) # Mouse + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + feed_pet() + new_state = load_state() + assert new_state["mood"] > 60 + +# feed_pet(): invalid case - insufficient funds +def test_feed_pet_insufficient_funds(monkeypatch, capsys): + state = load_state() + state["money"] = 0 + save_state(state) + inputs = iter(["1"]) + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + feed_pet() + captured = capsys.readouterr().out + assert "Not enough coins" in captured + +# feed_pet(): invalid case - invalid menu item +def test_feed_pet_invalid_choice(monkeypatch, capsys): + state = load_state() + state["money"] = 500 + save_state(state) + + monkeypatch.setattr(builtins, "input", lambda _: "9") + feed_pet() + + captured = capsys.readouterr().out + assert "Invalid choice" in captured + +# feed_pet(): menu item 6 - custom food success +def test_feed_pet_custom_food(monkeypatch): + state = load_state() + state["money"] = 500 + state["mood"] = 80 + save_state(state) + + inputs = iter(["6", "pancakes"]) + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + + feed_pet() + new_state = load_state() + assert new_state["mood"] > 80 + assert new_state["money"] < 500 + +# feed_pet(): menu item 6 - custom food empty +def test_feed_pet_custom_food_empty_name(monkeypatch, capsys): + # if user input is empty, mystery prey + state = load_state() + state["money"] = 500 + save_state(state) + + inputs = iter(["6", ""]) + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + + feed_pet() + captured = capsys.readouterr().out + assert "mystery prey" in captured + +# feed_pet(): menu item 7 - return +def test_feed_pet_return(monkeypatch): + # no state change should happen + state = load_state() + state["money"] = 500 + state["mood"] = 50 + save_state(state) + + monkeypatch.setattr(builtins, "input", lambda _: "7") + feed_pet() + new_state = load_state() + + assert new_state["money"] == 500 + assert new_state["mood"] == 50 + +# feed_pet(): with parameter - mouse (ball python food) +def test_feed_pet_with_parameter_mouse(capsys): + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + feed_pet("mouse") + + new_state = load_state() + captured = capsys.readouterr().out + assert new_state["mood"] == 68 # 60 + 8 + assert new_state["money"] == 450 # 500 - 50 + assert "mouse" in captured.lower() + assert "🐭" in captured + +# feed_pet(): with parameter - invalid food name +def test_feed_pet_invalid_food_name(capsys): + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + feed_pet("invalid_food") + + new_state = load_state() + captured = capsys.readouterr().out + assert new_state["mood"] == 60 # unchanged + assert new_state["money"] == 500 # unchanged + assert "Invalid food name" in captured + + +# feed_pet(): test all ball python foods +def test_feed_pet_rat(capsys): + """Test feeding rat to ball python""" + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + feed_pet("rat") + + new_state = load_state() + assert new_state["mood"] == 75 # 60 + 15 + assert new_state["money"] == 420 # 500 - 80 + + +def test_feed_pet_cricket(capsys): + """Test feeding cricket to ball python""" + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + feed_pet("cricket") + + new_state = load_state() + assert new_state["mood"] == 65 # 60 + 5 + assert new_state["money"] == 470 # 500 - 30 + + +def test_feed_pet_quail(capsys): + """Test feeding quail to ball python""" + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + feed_pet("quail") + + new_state = load_state() + assert new_state["mood"] == 80 # 60 + 20 + assert new_state["money"] == 370 # 500 - 130 + + +def test_feed_pet_rabbit(capsys): + """Test feeding rabbit to ball python""" + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + feed_pet("rabbit") + + new_state = load_state() + assert new_state["mood"] == 85 # 60 + 25 + assert new_state["money"] == 350 # 500 - 150 + + +def test_ball_python_theme_in_output(capsys): + """Test that ball python theme appears in feed output""" + state = load_state() + state["money"] = 500 + state["mood"] = 60 + save_state(state) + + feed_pet("mouse") + + captured = capsys.readouterr().out + assert "ball python" in captured.lower() \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..9e61049 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,93 @@ +import sys, os, pytest +from datetime import datetime + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from datetime import timedelta +from study_pet.pet.core import update_pet, get_status, check_daily_mood_decay +from study_pet.data_manager import load_state, save_state, reset_state + + +@pytest.fixture(autouse=True) +def clean_state(): + reset_state() + yield + reset_state() + + +def test_update_pet_increases_level(): + state = load_state() + state["total_study_time"] = 10.0 + save_state(state) + + new_state = update_pet() + assert new_state["level"] >= 3 + + +def test_update_pet_sets_last_study_date(): + update_pet() + state = load_state() + today = datetime.now().strftime("%Y-%m-%d") + assert state["last_study_date"] == today + + +def test_get_status_reflects_live_session_time(monkeypatch): + state = load_state() + state["total_study_time"] = 2.0 + state["last_session_start"] = 0 + save_state(state) + + monkeypatch.setattr("time.time", lambda: 3600) + status = get_status() + assert "3.00" in status or "2.99" in status + + +def test_check_daily_mood_decay_reduces_mood(): + s = load_state() + s["mood"] = 80 + old_date = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d") + s["last_open_date"] = old_date + save_state(s) + new_mood = check_daily_mood_decay() + assert new_mood < 80 + + +def test_get_status_shows_ball_python_theme(): + """Test that status display includes ball python theme""" + status = get_status() + assert "Ball Python" in status + assert "🐍" in status + + +def test_get_status_shows_morph(): + """Test that status display includes morph information""" + state = load_state() + state["morph"] = "Albino" + save_state(state) + + status = get_status() + assert "Morph:" in status + assert "Albino" in status + + +def test_get_status_shows_default_morph(): + """Test that status displays default morph""" + status = get_status() + assert "Normal/Wild Type" in status or "Morph:" in status + + +def test_ball_python_mood_descriptions(): + """Test that ball python mood descriptions are used""" + state = load_state() + + # Test high mood (slithering happily) + state["mood"] = 90 + save_state(state) + status = get_status() + assert "Slithering happily" in status or "🐍" in status + + # Test low mood (lethargic) + state["mood"] = 10 + save_state(state) + status = get_status() + assert "lethargic" in status.lower() or "🐍" in status diff --git a/tests/test_data_manager.py b/tests/test_data_manager.py new file mode 100644 index 0000000..be137f6 --- /dev/null +++ b/tests/test_data_manager.py @@ -0,0 +1,57 @@ +import os +import sys +import json +import pytest + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from study_pet import data_manager as dm + + +@pytest.fixture(autouse=True) +def cleanup_data_file(): + if os.path.exists(dm.DATA_PATH): + os.remove(dm.DATA_PATH) + yield + if os.path.exists(dm.DATA_PATH): + os.remove(dm.DATA_PATH) + + +def test_load_state_creates_default_file(): + state = dm.load_state() + assert os.path.exists(dm.DATA_PATH) + assert "name" in state + assert state["level"] == 1 + assert "session_tasks_planned" in state + assert "session_tasks_completed" in state + assert "morph" in state + assert state["session_tasks_planned"] == 0 + assert state["session_tasks_completed"] == 0 + assert state["morph"] == "Normal/Wild Type" + + +def test_save_state_writes_to_file(): + test_state = {"name": "Testy", "level": 5, "experience": 99} + dm.save_state(test_state) + with open(dm.DATA_PATH, "r") as f: + data = json.load(f) + assert data["name"] == "Testy" + assert data["level"] == 5 + + +def test_reset_state_resets_to_default(): + dm.save_state({"name": "WrongPet", "level": 10, "session_tasks_planned": 5, "morph": "Custom"}) + dm.reset_state() + state = dm.load_state() + assert state["name"] == "Guido" + assert state["level"] == 1 + assert state["session_tasks_planned"] == 0 + assert state["session_tasks_completed"] == 0 + assert state["morph"] == "Normal/Wild Type" + + +def test_save_and_reload(): + s = {"name": "A", "level": 5} + dm.save_state(s) + re = dm.load_state() + assert re["name"] == "A" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..08be1ce --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,100 @@ +import sys, os, pytest + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from study_pet.__main__ import main + +# start option +def test_main_start(monkeypatch): + calls = {"started": False} + monkeypatch.setattr( + "study_pet.__main__.start_session", lambda: calls.update(started=True) + ) + monkeypatch.setattr("sys.argv", ["prog", "start"]) + main() + assert calls["started"] + +# end option +def test_main_end(monkeypatch): + calls = {"ended": False} + monkeypatch.setattr( + "study_pet.__main__.end_session", lambda: calls.update(ended=True) + ) + monkeypatch.setattr("sys.argv", ["prog", "end"]) + main() + assert calls["ended"] + +# status option +def test_main_status(monkeypatch, capsys): + monkeypatch.setattr("sys.argv", ["prog", "status"]) + monkeypatch.setattr("study_pet.__main__.get_status", lambda: "Pet OK") + main() + captured = capsys.readouterr().out + assert "Pet OK" in captured + +# feed option +def test_main_feed(monkeypatch): + calls = {"fed": False} + monkeypatch.setattr( + "study_pet.__main__.feed_pet", lambda x=None: calls.update(fed=True) + ) + monkeypatch.setattr("sys.argv", ["prog", "feed"]) + main() + assert calls["fed"] + +# feed option with parameter +def test_main_feed_with_parameter(monkeypatch): + calls = {"fed_with": None} + monkeypatch.setattr( + "study_pet.__main__.feed_pet", lambda x=None: calls.update(fed_with=x) + ) + monkeypatch.setattr("sys.argv", ["prog", "feed", "mouse"]) + main() + assert calls["fed_with"] == "mouse" + +# rename option with parameter +def test_main_rename_with_parameter(monkeypatch): + calls = {"renamed_with": None} + monkeypatch.setattr( + "study_pet.__main__.rename_pet", lambda x=None: calls.update(renamed_with=x) + ) + monkeypatch.setattr("sys.argv", ["prog", "rename", "NewPetName"]) + main() + assert calls["renamed_with"] == "NewPetName" + +# morph option +def test_main_morph(monkeypatch): + calls = {"morph_set": False} + monkeypatch.setattr( + "study_pet.__main__.set_morph", lambda x=None: calls.update(morph_set=True) + ) + monkeypatch.setattr("sys.argv", ["prog", "morph"]) + main() + assert calls["morph_set"] + +# morph option with parameter +def test_main_morph_with_parameter(monkeypatch): + calls = {"morph_with": None} + monkeypatch.setattr( + "study_pet.__main__.set_morph", lambda x=None: calls.update(morph_with=x) + ) + monkeypatch.setattr("sys.argv", ["prog", "morph", "Albino"]) + main() + assert calls["morph_with"] == "Albino" + +# unknown command +def test_main_invalid_command(monkeypatch, capsys): + monkeypatch.setattr("sys.argv", ["prog", "unknown"]) + main() + captured = capsys.readouterr().out + assert "Unknown command" in captured + +# two tries +def test_main_menu_invalid_then_exit(monkeypatch, capsys): + monkeypatch.setattr("sys.argv", ["prog"]) # main menu + inputs = iter(["invalid", "5"]) # 1) invalid menu choice, then 2) exit + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + main() + captured = capsys.readouterr().out + assert "Invalid option. Try again." in captured \ No newline at end of file diff --git a/tests/test_morph.py b/tests/test_morph.py new file mode 100644 index 0000000..57fa91f --- /dev/null +++ b/tests/test_morph.py @@ -0,0 +1,128 @@ +import sys, os, pytest, builtins + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from study_pet.pet.actions import set_morph +from study_pet.data_manager import load_state, save_state, reset_state + + +@pytest.fixture(autouse=True) +def clean_state(): + reset_state() + yield + reset_state() + + +def test_set_morph_default(): + """Test that default morph is Normal/Wild Type""" + state = load_state() + assert state["morph"] == "Normal/Wild Type" + + +def test_set_morph_with_parameter(capsys): + """Test setting morph with parameter""" + set_morph("Albino") + state = load_state() + captured = capsys.readouterr().out + + assert state["morph"] == "Albino" + assert "Albino" in captured + + +def test_set_morph_interactive(monkeypatch, capsys): + """Test setting morph through interactive menu""" + monkeypatch.setattr(builtins, "input", lambda _: "5") # Choose Mojave + set_morph() + state = load_state() + captured = capsys.readouterr().out + + assert state["morph"] == "Mojave" + assert "Mojave" in captured + + +def test_set_morph_invalid_parameter(capsys): + """Test setting morph with invalid parameter""" + set_morph("InvalidMorph") + state = load_state() + captured = capsys.readouterr().out + + # Should remain default + assert state["morph"] == "Normal/Wild Type" + assert "Invalid morph name" in captured + + +def test_set_morph_cancel(monkeypatch, capsys): + """Test canceling morph selection""" + monkeypatch.setattr(builtins, "input", lambda _: "12") # Choose cancel option (len(morphs) + 1) + set_morph() + state = load_state() + captured = capsys.readouterr().out + + assert state["morph"] == "Normal/Wild Type" + assert "cancelled" in captured.lower() + + +def test_set_morph_case_insensitive(capsys): + """Test that morph names are case insensitive""" + set_morph("pAsTel") + state = load_state() + + assert state["morph"] == "Pastel" + + +def test_all_morphs_selectable(monkeypatch): + """Test that all 10 morphs can be selected""" + morphs = [ + ("1", "Normal/Wild Type"), + ("2", "Albino"), + ("3", "Pastel"), + ("4", "Spider"), + ("5", "Mojave"), + ("6", "Pinstripe"), + ("7", "Clown"), + ("8", "Banana"), + ("9", "Black Pastel"), + ("10", "Cinnamon") + ] + + for choice, expected_morph in morphs: + reset_state() + monkeypatch.setattr(builtins, "input", lambda _, c=choice: c) + set_morph() + state = load_state() + assert state["morph"] == expected_morph, f"Failed to set {expected_morph}" + + +def test_custom_morph_interactive(monkeypatch, capsys): + """Test creating a custom morph through interactive menu""" + inputs = iter(["11", "Blue Dream"]) + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + set_morph() + state = load_state() + captured = capsys.readouterr().out + + assert state["morph"] == "Blue Dream" + assert "custom" in captured.lower() + + +def test_custom_morph_empty_name(monkeypatch, capsys): + """Test that empty custom morph name is rejected""" + inputs = iter(["11", ""]) + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + set_morph() + state = load_state() + captured = capsys.readouterr().out + + assert state["morph"] == "Normal/Wild Type" # Should remain default + assert "cannot be empty" in captured.lower() + + +def test_custom_morph_with_parameter_invalid(capsys): + """Test that invalid morph names via parameter don't update state""" + set_morph("Super Pastel Mojave") + state = load_state() + captured = capsys.readouterr().out + + # Should remain default since it's not a preset morph + assert state["morph"] == "Normal/Wild Type" + assert "Invalid morph name" in captured diff --git a/tests/test_tracker.py b/tests/test_tracker.py new file mode 100644 index 0000000..cfedcef --- /dev/null +++ b/tests/test_tracker.py @@ -0,0 +1,145 @@ +import sys, os +import time +import pytest +import builtins + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +from study_pet.tracker import ( + start_session, + end_session, + get_total_time, + reset_sessions, + manual_close, + _auto_end_session, +) +from study_pet.data_manager import load_state, reset_state, save_state + + +@pytest.fixture(autouse=True) +def clean_data_file(): + reset_state() + yield + reset_state() + + +def test_start_session_creates_timestamp(monkeypatch): + monkeypatch.setattr(builtins, "input", lambda _: "3") + start_session() + state = load_state() + assert state["last_session_start"] is not None + assert state["session_tasks_planned"] == 3 + + +def test_end_session_updates_total_time(monkeypatch): + inputs = iter(["5", "3"]) # 5 tasks planned, 3 completed + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + start_session() + time.sleep(0.5) + end_session() + state = load_state() + assert state["total_study_time"] > 0 + assert state["last_session_start"] is None + assert state["session_tasks_planned"] == 0 # reset after session ends + + +def test_get_total_time_matches_state(): + state = load_state() + state["total_study_time"] = 12.34 + save_state(state) + total_time = get_total_time() + assert abs(total_time - 12.34) < 0.001 + + +def test_reset_sessions_clears_active_session(monkeypatch): + monkeypatch.setattr(builtins, "input", lambda _: "2") + start_session() + reset_sessions() + state = load_state() + assert state["last_session_start"] is None + + +def test_end_session_without_start_does_not_crash(): + try: + end_session() + assert True + except Exception as e: + pytest.fail(f"end_session() raised an unexpected exception: {e}") + + +def test_auto_end_session_respects_manual_close(monkeypatch): + monkeypatch.setattr(builtins, "input", lambda _: "1") + start_session() + monkeypatch.setattr("study_pet.tracker.manual_close", True) + _auto_end_session() + state = load_state() + # still active because manual close skipped + assert state["last_session_start"] is not None + + +def test_task_rewards_coins(monkeypatch): + """Test that completing tasks rewards coins correctly.""" + inputs = iter(["5", "3"]) # 5 tasks planned, 3 completed + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + + state = load_state() + initial_money = state["money"] + + start_session() + time.sleep(0.1) + end_session() + + state = load_state() + # 3 tasks * 75 coins per task = 225 coins + assert state["money"] == initial_money + 225 + assert state["session_tasks_completed"] == 3 + + +def test_no_tasks_planned_no_rewards(monkeypatch): + """Test that sessions with 0 tasks don't ask for completion or give rewards.""" + monkeypatch.setattr(builtins, "input", lambda _: "0") + + state = load_state() + initial_money = state["money"] + + start_session() + time.sleep(0.1) + end_session() + + state = load_state() + # No tasks, no rewards + assert state["money"] == initial_money + assert state["session_tasks_planned"] == 0 + + +def test_completing_more_tasks_than_planned(monkeypatch): + """Test completing more tasks than planned with confirmation.""" + inputs = iter(["3", "5", "y"]) # 3 planned, 5 completed, confirm yes + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + + state = load_state() + initial_money = state["money"] + + start_session() + time.sleep(0.1) + end_session() + + state = load_state() + # 5 tasks * 75 coins = 375 coins + assert state["money"] == initial_money + 375 + assert state["session_tasks_completed"] == 5 + + +def test_start_session_validates_task_input(monkeypatch, capsys): + """Test that start_session validates numeric input for tasks.""" + inputs = iter(["invalid", "-1", "3"]) # invalid, negative, then valid + monkeypatch.setattr(builtins, "input", lambda _: next(inputs)) + + start_session() + + captured = capsys.readouterr().out + assert "Please enter a valid number" in captured or "Please enter a positive number" in captured + + state = load_state() + assert state["session_tasks_planned"] == 3